Dans l’architecture PRAWD, une procédure stockée renvoie toutes les informations de la page. L’alias de la première colonne permet de nommer et “typer” chaque resultset, et les autres alias permettent de nommer et typer chaque colonne.

Le rôle de et de dans

Je vous ai présenté mon architecture “PRAWD” précédemment. Dans celle-ci, je considère le serveur MySQL et le code PHP comme deux entités très distinctes et indépendantes. En conséquence, chaque page PHP fait un appel de procédure au serveur MySQL pour récupérer, d’un coup, toutes les données dont cette page a besoin. En somme, le serveur MySQL devient simplement le serveur d’API de mon jeu web, et le code PHP qui l’appelle se charge uniquement de transformer ces données issues du MySQL pour les envoyer au client (navigateur) qui les a demandées. Par exemple, si le client a demandé du JSON, le code PHP va globalement se contenter de transformer les données renvoyées par la procédure SQL en un bean JSON. Si le client a demandé du HTML, le serveur PHP se chargera de formatter la page web à partir de ces données retournées par la procédure SQL.

Exemple pratique

Difficile de vous présenter la façon dont mon PHP et mon MySQL sont liés sans passer par un exemple pratique (ne faire que de la théorie sera lourd à lire et à écrire). Voici donc un extrait du projet Isometry, qui présente la page listant les cases du plateau de jeu. Nous allons disséquer les codes ensemble. Les sources peuvent être téléchargées ici: https://toile.reinom.com/wp-content/uploads/2017/05/prawd-php-mysql.zip pour les essayer, créez une base de données prawd dans votre serveur local, et exécutez chacun des fichiers SQL, dans n’importe quel ordre (me semble-t-il).

Le handler

C’est le “controlleur” de la page: il va faire l’appel à la procédure SQL, fera les éventuelles actions annexes (copiers des fichiers, envoyer des mails, etc) et retournera un bean contenant les données de la page. Ce bean sera alors formatté par le serveur, en utilisant la classe HtmlContent. Pour simplifier, j’ai retiré cette partie “serveur” de l’exemple: les données du Handler sont donc directement formattées et retournées.

/** @private */
class Handler {

	public function handleRequest() {
		// $idPlayer = Config::get()->getSession()->getLoggedIdUser();
		$idPlayer = filter_input(INPUT_GET, 'idPlayer');
		
		$map = new ModelBean(
				PdoIsometryUtils::executeProcedure(
						"map_world",
						array($idPlayer, $idPlayer ? true : false),
						array("user:1" => new InvalidInputException("Ce joueur n'existe pas.", $idPlayer, null))));
		$map->idPlayer = $idPlayer;
		
		// This asctually belongs to another "Server" class, but let's make things simple here
		if (filter_input(INPUT_GET, 'json')) {
			header('Content-Type: application/json');
			echo json_encode($map);
		} else {
			$formatter = new HtmlContent($map);
			echo $formatter->content();
		}
	}
}
Le controlleur de la page demande les infos à la procédure, puis les traite éventuellement avant de les formatter et de les retourner

La procédure stockée

Dans cet exemple, la procédure stockée accepte 2 paramètres (identifiant du joueur et booléen indiquant si on veut toute la carte ou juste les cases du joueur). Elle lèvera une exception via SIGNAL si le joueur n’existe pas. Notez que ce genre de test est très facile à faire dans le serveur SQL, grâce à la procédure-utilitaire assert que j’y ai créé. Sinon, si le joueur existe, la procédure renvoie la bounding box de la carte (ie: les coordonnées du rectangle enblogant les cases de la carte) ainsi que la liste des cases de cette carte, avec leurs informations.

Comme il s’agit d’une procédure SQL, un gros avantage de l’architecture PRAWD est de vous permettre de l’appeler directement, sans passer par PHP: un simple CALL map_world(NULL, FALSE); vous renverra toutes les cases de la carte. Un CALL map_world(1, TRUE); vous renverra une exception SQL, qui signifie que le joueur n’existe pas.

DELIMITER $$

DROP PROCEDURE IF EXISTS `map_world`$$

CREATE PROCEDURE `map_world`(
	IN `idPlayer` INT UNSIGNED,
	IN `myCasesOnly` TINYINT(1) UNSIGNED
)
LANGUAGE SQL
NOT DETERMINISTIC
MODIFIES SQL DATA
SQL SECURITY INVOKER
COMMENT ''
BEGIN

	CALL assert(myCasesOnly = FALSE OR EXISTS(SELECT 1 FROM player AS p WHERE p.id = idPlayer), 1);
	
-- 	CALL simulation();
-- @TODO Use variables, but for unknown reason, using @maxX = MAX(...) will return "NULL" ?!
	SELECT
		MIN(m.x - 1) AS `view:unic;minX:double`,
		MIN(m.y - 1)/2.0 AS `minY:double`,
		MAX(m.x + 1) AS `maxX:double`,
		MAX(m.y + 1)/2.0 AS `maxY:double`,
		(MAX(m.x + 1) - MIN(m.x - 1)) AS `sizeX:double`,
		(MAX(m.y + 1) - MIN(m.y - 1))/2.0 AS `sizeY:double`
	FROM map AS m
	WHERE (myCasesOnly IS FALSE OR m.id_player = idPlayer)
	;

	SELECT
		m.id AS `cases:list;id:id`,
		m.x AS `x0:double`,
		m.y AS `y0:double`,
		m.x AS `x:double`,
		(m.y/2) AS `y:double`,
		m.`type` AS `type:string`,
		mb.id_building AS `idBuilding:id`,
		mb.production_factor AS `production:double`,
		(p.id = idPlayer) AS `isMine:bool`,
		IF(p.id = idPlayer, missing.id_resource, NULL) AS `idResourceMissing:id`,
		IF(p.id = idPlayer, rm.`name`, NULL) AS `resourceMissingName:string`,
		p.id AS `idPlayer:id`,
		p.pseudo AS `playerName:string`,
		CONCAT('M ', m.x, ',', (m.y/2), ' m0,-0.5 l1,0.5 l-1,0.5 l-1,-0.5 z') AS `shape:svgpath`
	FROM map AS m
	LEFT JOIN map_building AS mb ON mb.id_case = m.id
	LEFT JOIN player AS p ON p.id = m.id_player
	LEFT JOIN (
		SELECT 
			mb.id_case, 
			MIN(br.id_resource) AS id_resource
		FROM map_building AS mb
		INNER JOIN building_resource AS br ON br.id_building = mb.id_building AND br.production_per_day < 0
		LEFT JOIN map_storage AS ms ON ms.id_case = mb.id_case AND ms.id_resource = br.id_resource
		WHERE 
			br.production_per_day < 0 
			AND IFNULL(ms.quantity,0) + br.production_per_day*real_seconds_to_game_days(UNIX_TIMESTAMP() - UNIX_TIMESTAMP(mb.date_last_production)) <= 0
		GROUP BY mb.id_case
	) AS missing ON missing.id_case = m.id
	LEFT JOIN resource AS rm ON rm.id = missing.id_resource
	WHERE (myCasesOnly IS FALSE OR m.id_player = idPlayer)
	ORDER BY m.y ASC, m.x ASC
	;

END$$

DELIMITER ;
La procédure SQL

Les noms de colonnes

Il m’est utile de passer des méta-données à PHP (ou à l’appelant du serveur SQL) pour lui indiquer, entre autre, le nom de chaque colonne, son typage, ainsi que le nom du résultset et son “typage” (est-ce un resultset n’ayant qu’une liste, donc un unic, comme le premier? ou une liste de lignes, donc un list, comme le second?). Ces méta-données sont passées dans le nom des colonnes, car MySQL n’offre pas d’autre moyen de le faire! J’ai choisi moi-même la syntaxe: elle n’est donc pas standard. En pratique, la première colonne indique nom_du_resultset:type_de_resultset;nom_de_colonne:type_de_colonne et les autres n’indiquent que les données sur la colonne, soient nom_de_colonne:type_de_colonne. Ces informations seront traitées par le code PHP pour faire les cast et autres vérifications nécessaires.

L’appel de procédure: PdoUtils

Cette classe fait le liant entre PHP et MySQL. Elle lance l’appel de procédure, et gère son retour. En cas d’erreur (exception levée via SIGNAL dans la procédure), elles utiliseront les handlers d’erreurs donnés par la page (dernier paramètre d’appel) pour traiter l’exception, et le plus souvent, en renvoyer une autre qui représente la véritable erreur.

getPdo(), $procName, $parameters, new PdoErrorCatcher($exceptions));
	}
}
getPdo()
	 * @param string $procName Procedure name
	 * @param array $inputParameters Procedure parameters
	 * @param IPdoCatcher $catcher How exceptions are caught (ie: use PdoErrorCatcher)
	 * @return PdoResultsetBean[] The SQL result data, pass it to your ModelBean then
	 * @throws SqlUnknownException 
	 * @throws LogicException 
	 */
	public static function executeProcedure(
			PDO $pdo,
			$procName, 
			array $inputParameters,
			IPdoCatcher $catcher) {
		
		// MySQL cannot deal with PHP's bool values (or is it PDO?) so turn them into a int
		// This converter could also be used for other stuff like that
		$parameters = array_map(function ($value) {
			return is_bool($value)
					? intval($value)
					: $value;
		}, $inputParameters);
		
		$queryParams = implode(',', array_fill(0, count($parameters), '?'));
		$queryString = "CALL $procName($queryParams)";
		$statement = $pdo->prepare($queryString);
		$resultData = array();
		
		try {
			$statement->execute($parameters);

			do {
				if ($statement->columnCount() < = 0) {
					// Skip empty result sets
					continue;
				}
				
				$columnMeta = $statement->getColumnMeta(0);
				
				$resultsetDescription = null;
				if (!preg_match(static::RESULTSET_DESCRIPTION, $columnMeta['name'], $resultsetDescription)) {
					throw new SqlUnknownException(new PDOException("Invalid first column meta name `{$columnMeta['name']}` in `$queryString`"));
				}
				
				if (array_key_exists(3, $resultsetDescription)) {
					// Skip these resultsets (starts with "@" or are "SELECT 1 FROM..."
					continue;
				}
				
				$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
				$resultData[] = new PdoResultsetBean($resultsetDescription[1], $resultsetDescription[2], $rows);
			} while($statement->nextRowset());
		} catch (PDOException $ex) {
			$catcher->catchError(new PdoProcedureException($pdo, $procName, $parameters, $ex));
		}
		$statement->closeCursor();
		return $resultData;
	}
}

Comme vous le constatez, la classe PdoUtils construit, prépare et exécute la requête SQL, en ayant éventuellement casté les paramètres de son appel. En cas d’exception, cette classe va faire appel à un “catcher d’erreur” qui aura pour mission de traiter l’exception en question.

Le catcher d’erreur PdoErrorCatcher

Cette classe est en charge de gérer les exceptions levées par l’appel de procédure, qu’elles viennent d’un SIGNAL, d’une foreign key, ou d’un index unique (ou autre).

Les exception PDOException précédentes doivent être traitées, mais elles peuvent provenir du corps de la procédure (le SIGNAL vu précédemment), mais aussi d’erreurs de syntaxe SQL, de tables manquantes, de foreign keys qui échouent, de clef uniques en double, etc. Le catcher doit donc être capable de faire la différence entre ces erreurs, et de proposer un moyen simple de les corriger si possible. Par exemple, une erreur utilisateur, levée par SIGNAL, devrait nécesairement être associée à un code de traitement.

 $exception) {
			if (!preg_match('~^(user|index|fk|range):.+$~', $code)) {
				throw new LogicException("Invalid PDO catcher code $code");
			}
		}
		
		$this->catchers = $exceptions;
	}

	private function throwException($code, PDOException $previous) {
		return array_key_exists($code, $this->catchers)
				? $this->catchers[$code]
				: new LogicException("Missing PDO error catcher for code $code", 0x00, $previous);
	}
	
	public function catchError(PDOException $ex) {
		$fullSqlState = ($ex->errorInfo[0] === '45000')
				? '45000:*'
				: $ex->errorInfo[0] . ":" . $ex->errorInfo[1];
		
		switch ($fullSqlState) {
			case '45000:*':
				// SQL user error
				throw $this->throwException(static::CATCHER_USER . $ex->errorInfo[1], $ex);
				
			case '23000:1062':
				// Duplicate index entry
				$indexMessage = null;
				if (!preg_match('~^(?:Duplicate entry|Duplicata du champ) \'(.+)\'(?: for key | pour la clef )\'(.+)\'$~', $ex->errorInfo[2], $indexMessage)) {
					throw new LogicException("Invalid PDO message {$ex->errorInfo[2]} for duplicate index error", 0x00, $ex);
				}
				
				throw $this->throwException(static::CATCHER_DUPLICATEINDEX . $indexMessage[2], $ex);
				
			case '23000:1452':
				// Foreign key error
				$fkMessage = null;
				if (!preg_match('~^Cannot add or update a child row: a foreign key constraint fails \\('
						. '`.+`\\.`.+`, CONSTRAINT `(.+)` FOREIGN KEY .*\\)$~', $ex->errorInfo[2], $fkMessage)) {
					throw new LogicException("Invalid PDO message {$ex->errorInfo[2]} for FK error", 0x00, $ex);
				}
				
				throw $this->throwException(static::CATCHER_FKFAIL . $fkMessage[1], $ex);
				
			case '22003:1264':
				// Out of range
				$outOfRangeMessage = null;
				if (!preg_match('~^.*Out of range value for column \'(.+)\'.*$~', $ex->errorInfo[2], $outOfRangeMessage)) {
					throw new LogicException("Invalid PDO message {$ex->errorInfo[2]} for Out of Range error", 0x00, $ex);
				}
				
				throw $this->throwException(static::CATCHER_OUTOFRANGE . $outOfRangeMessage[1], $ex);
				
			case 'HY000:1366':
				// Invalid type (ie: passing INT instead of VARCHAR)
				
			default:
				throw new SqlUnknownException($ex);
		}
	}
}
Le catcher d’erreur

Le catcher générique d’erreur que j’utilise va donc prendre l’erreur SQL initiale et suivant son code SQL, il exécutera différents traitements (switch). Dans la majorité des cas ici, le catcher va tenter de récupérer des informations de l’erreur SQL (nom de l’index en double par exemple), puis il va consulter le mapping que le Handler lui a passé en paramètre. Ce mapping va alors permettre de convertir l’erreur SQL en une erreur PHP classique, indiquant par exemple que le joueur n’existe pas. Dans le cas d’une erreur de clef unique, le mapping va de même permettre d’associer cette erreur à une exception, comme AccountEmailAlreadyExists. De cette manière, je me repose sur le SQL pour tester l’unicité d’une donnée dans la base (l’email d’inscription) et en cas d’erreur sur cette unicité, je peux effectuer un traitement (lancer une exception représentant une erreur d’entrée dans un formulaire HTML par exemple).

Notez qu’ici, le mapping ne peut être constitué que d’exceptions (return array_key_exists($code, $this->catchers) ? $this->catchers[$code] : new LogicException("Missing PDO error catcher for code $code", 0x00, $previous);). Donc, dans la pratique, je lève parfois une exception spécifique qui sera catchée ensuite par le contenu du Handler, comme une exception PHP classique. Je pourrai autoriser le passage de callbacks dans le mapping si cela s’avérait nécessaire.

Le retour de PdoUtils

Cette classe est en charge de parser le nom_resultset:type_resultset;... de chacun des resultset de la procédure stockée.

Si tout se passe bien, la classe PdoUtils renvoie une liste de PdoResultsetBean, chacun contenant le nom du resultset, son type et ses lignes de données. Ces beans sont alors passés en paramètre à ModelBean, une classe spécifique à chaque page. Cette classe représente les données de la page que le client du PHP verra. Lorsqu’elle est construite, elle lit les beans PdoResultsetBean et construit ses propriétés à partir de cette lecture. Suivant le type de resultset de chaque bean, la propriété associée de ModelBean sera donc soit une liste de beans (des ASqlRow) soit un seul bean ASqlRow, soit null. A ce stade de la construction, il est possible, comme c’est le cas dans le code ci-dessous, de fusionner les resultset ayant le même nom et étant de type list. Cela permet parfois de faire des requêtes SQL un peu plus simple, même si un UNION est généralement préférable.

 Netbeans don't know what it returns
	 * new ModelBean(PdoUtils::...()) -> Netbeans knows it's a ModelBean
	 * @param PdoResultsetBean[] $sqlResults Result from the SQL
	 * @throws LogicException Invalid resultset type (unic, list, etc)
	 */
	public function __construct(array $sqlResults = array()) {
		foreach ($sqlResults as $resultsetBean) {
			/* @var framework\pdo\PdoResultsetBean $resultsetBean */
			
			// The classname of the PHP bean for this resultset; it's ($this's classname . resultset's name)
			$resultsetName = $resultsetBean->getName();
			$class = static::class . strtoupper($resultsetName{0}) . substr($resultsetName, 1);
			
			/// @todo On DEV only, raise an exception if the object doesn't have the field "$resultsetName" set
			// Constructor of the resultset's bean
			switch ($resultsetBean->getType()) {
				case static::RESULTSET_TYPE_LIST:
					$list = array();
					foreach ($resultsetBean->getRows() as $row) {
						$list[] = new $class($row);
					}
					$this->$resultsetName = $this->$resultsetName ? array_merge($this->$resultsetName, $list) : $list;
					break;
					
				case static::RESULTSET_TYPE_UNIC:
					if ($this->$resultsetName) {
						throw new SqlUnknownException(new PDOException("Unic field `$resultsetName` already contains data"));
					}
					
					$rows = $resultsetBean->getRows();
					if (!$rows) {
						throw new SqlUnknownException(new PDOException("Unic field `$resultsetName` is empty, use `single` instead"));
					}
					
					$this->$resultsetName = new $class(reset($rows));
					break;
				
				case static::RESULTSET_TYPE_SINGLE:
					if ($this->$resultsetName) {
						throw new SqlUnknownException(new PDOException("Single field `$resultsetName` already contains data"));
					}
					
					$rows = $resultsetBean->getRows();
					$this->$resultsetName = $rows ? new $class(reset($rows)) : null;
					break;
				
				default:
					throw new LogicException("Unknown field type `{$resultsetBean->getType()}` from SQL field `$resultsetName`");
			}
		}
	}
}
Le code traitant le resultset dans sa globalité

Les lignes de données: ASqlRow

Cette classe se charge de parser le ...;colonne_name:colonne_type issu du SQL, pour caster la colonne dans le bon type de données PHP, et faire d’éventuelles vérifications. C’est ce qui se cache derrière les appels à new $class($row). Dans l’exemple présenté, chaque ligne de résultat est donc un ModelBean* qui hérite de ASqlRow. Les données de ces ModelBean* sont donc proprement castées et vérifiées, pour que le Handler n’ait plus qu’à les utiliser.

 $fieldValue) {
			$fieldDescription = null;
			if (!preg_match(static::ROW_DESCRIPTION, $fieldKey, $fieldDescription)) {
				throw new SqlUnknownException(new PDOException("Bad field name $fieldKey"));
			}

			switch ($fieldDescription[2]) {
				case static::ROW_INT:
					$value = ($fieldValue === null ? null : (int)$fieldValue);
					break;

				case static::ROW_ID:
					$value = ($fieldValue === null ? null : (int)$fieldValue);
					if ($value !== null && $value < = 0) {
						throw new SqlUnknownException(new PDOException("Invalid negative ID value for field {$fieldDescription[1]}"));
					}
					break;

				case static::ROW_DOUBLE:
					$value = ($fieldValue === null ? null : (double)$fieldValue);
					break;

				case static::ROW_DATETIME:
					$value = ($fieldValue === null 
							? null 
							: new DateTimeImmutable(
									is_numeric($fieldValue) ? date('Y-m-d H:i:s', $fieldValue) : $fieldValue,
									new DateTimeZone('UTC')));
					break;

				case static::ROW_STRING:
					$value = ($fieldValue === null ? null : (string)$fieldValue);
					break;

				case static::ROW_SVGPATH:
					/// @TODO add a regex to validate SVG path's "d" attribute value
					$value = ($fieldValue === null ? null : (string)$fieldValue);
					break;

				case static::ROW_BOOL:
					$value = ($fieldValue === null ? null : (bool)$fieldValue);
					break;

				case static::ROW_IGNORE:
					// Ignore this row (usually, because it's only for setting an internal SQL variable)
					break;

				default:
					throw new SqlUnknownException(new PDOException("Unknown data type {$fieldDescription[2]}"));
			}
			/// @todo On DEV only, raise an exception if the object doesn't have this field set
			$this->{$fieldDescription[1]} = $value;
		}
	}

}

Conséquences de cette structure

Procédure SQL ou requêtes dans le Handler?

En fait, on pourrait se demander pourquoi passer par une procédure stockée? Pourquoi ne pas simplement mettre ses requêtes SQL dans le Handler, puisque la procédure est spécifique à la page? En fait, on peut tout à faire mettre ses requeêtes SQL dans le Handler et se passer d’une procédure SQL, mais cela implique plusieurs conséquences.

D’abord, il n’est plus possible d’appeler la procédure SQL sans passer par le code PHP. En d’autres mots, impossible de tester séparément le serveur web (formattage HTML, entrées utilisateur $_GET $_POST $_COOKIE etc) et le code métier de traitement des données (le contenu de la procédure). En passant par une procédure, on pourra à l’inverse s’assurer que celle-ci retourne bien les données attendue, et on saura si un bogue vient des données qu’on récupère de la BDD, ou de leur formattage. Il sera aussi possible de savoir si un soucis de performances vient du SQL ou du PHP. Enfin, il sera aisé de monter en version le serveur MySQL, car il suffit de rejouer les procédures stockées pour savoir si tout fonctionne.

Après, vous pourrez bien plus facilement refactorer votre code PHP et surtout votre code SQL, car les deux seront bien déliés. Si vous voulez changer le nom d’une table, un simplement remplacement de ce nom dans les fichier *.sql suffit (en attendant que les IDE proposent nativement ces refactorings!).

Egalement, niveau performances, vous évitez des allers-retours inutiles entre le PHP et le MySQL. C’est marginal niveau exécution, mais c’est plutôt pratique niveau intellectuel, car je trouve que cloisonner les choses ainsi permet de mieux savoir où travailler chacune d’elle (un soucis de perf sur une page un peu lente sera vite diagnostiqué: est-ce la procédure SQL qui est lente, ou le formattage des données dans PHP?). A voir aussi si MySQL ne serait pas capable de dresser de meilleures statistiques sachant que vous avez limité les points d’entrée du serveur aux seules procédures SQL.

Ensuite, NetBeans ne propose pas la coloration syntaxique des requêtes SQL dans un code PHP, et l’auto-complétion sera très très limitée. D’autres IDE peuvent palier ce soucis, mais je n’ai pas envie d’en changer (cela amène un surcout non négligeable en terme d’apprentissage). En revanche, dans un fichier SQL qui déclare la procédure stockée, NetBeans propose la coloration syntaxique, l’auto-complétion, et même l’exécution du fichier directement depuis l’IDE.

Au final, je préfère toujours utiliser une procédure plutôt que de placer les requêtes directement dans la page (même si cette page ne fait appel qu’à une seule requête SELECT sur une table ou une vue) car cela offrira des facilités d’extensions (on a besoin d’une nouvelle feature/donnée dans la page? on ajoute juste la requête correspondante dans le fichier SQL et c’est réglé). Se reposer sur les deux systèmes (procédure vs requête dans le code PHP) rendrait les choses inutilement complexes.

Lister les champs des ModelBean*

Les ModelBean* précédents peuvent être soit déclarés “vides” (sans contenu, juste un class ModelBeanView extends ASqlRow {}) ou lister les champs qu’ils contiennent. Personnellement, je préfère les lister. En effet, cela permet d’avoir facilement l’auto-complétion dans NetBeans, mais surtout, cela permet de s’assurer par deux fois que les données retournées par le SQL sont bien les bonnes. Supposons que je change, par mégarde, le nom d’un alias de colonne SQL. Ou que j’ajoute une colonne qui, en fait, existe déjà. Ou que j’en retire une, la pensant à tort inutile. Si les beans ont été déclarés vides, alors impossible de relever l’erreur. S’ils ont été proprement déclarés, alors il est possible au ASqlRow de lever une erreur car le champ du bean n’existe pas. Il peut être possible, de même, de lever une erreur si un champ du bean n’a pas été rempli. Toutefois, je n’utilise pas cette seconde approche. En effet, dans certaines page, des champs des beans ne viennent pas de la BDD. Ils peuvent venir des données entrées par l’utilisateur, ou simplement de la date du serveur. Dans ce cas, ces champs sont intentionnellement vides au sortir de la BDD. Pour cette raison, j’ai actuellement décidé de déclarer tous les champs des beans, et de lever une erreur uniquement si la BDD tente de modifier un champ qui n’existe pas. Gardez donc bien en tête que ModelBean* représente le modèle de votre page web, et non le modèle de la BDD. Les deux sont décorrélés, mais rien ne vous interdit de considérer qu’ils sont strictement égaux sur certaines pages (comme c’est assez souvent le cas).

/** @private */
class ModelBean extends ASqlResult {
	/** @var ModelBeanView $view */
	public $view;
	/** @var ModelBeanCases $cases */
	public $cases;
	/** @var int $idPlayer */
	public $idPlayer;
}

/** @private */
class ModelBeanView extends ASqlRow {
	/** @var double $minX */
	public $minX;
	/** @var double $maxX */
	public $maxX;
	/** @var double $minY */
	public $minY;
	/** @var double $maxY */
	public $maxY;
	/** @var double $sizeX */
	public $sizeX;
	/** @var double $sizeY */
	public $sizeY;
}

/** @private */
class ModelBeanCases extends ASqlRow {
	/** @var int $id */
	public $id;
	/** @var double $x */
	public $x;
	/** @var double $y */
	public $y;
	/** @var double $x0 */
	public $x0;
	/** @var double $y0 */
	public $y0;
	/** @var string $shape */
	public $shape;
	/** @var int $idBuilding */
	public $idBuilding;
	/** @var double $production */
	public $production;
	/** @var int $idResourceMissing */
	public $idResourceMissing;
	/** @var string $resourceMissingName */
	public $resourceMissingName;
	/** @var int $idPlayer */
	public $idPlayer;
	/** @var string $playerName */
	public $playerName;
	/** @var string $type */
	public $type;
}
Le modèle de page

Pour rappel, un exemple fonctionnel se trouve à l’adresse suivante: https://toile.reinom.com/wp-content/uploads/2017/05/prawd-php-mysql.zip. Je ne vais pas vous empêcher de réutiliser cette architecture et ces sources (bien au contraire!), donc considérez-les comme sous licence LGPL. Merci de me créditer et de laisser un lien vers ce blog si vous vous resservez de ces codes/de cette architecture.