diff --git a/src/Builder/BaseBuilder.php b/src/Builder/BaseBuilder.php index 44636a7..7486742 100644 --- a/src/Builder/BaseBuilder.php +++ b/src/Builder/BaseBuilder.php @@ -380,10 +380,14 @@ public function select($columns = '*'): static /** * Sélectionne avec un alias explicite + * + * @param Expression|string $column Colonnes à sélectionner */ - public function selectAs(string $column, string $alias): static + public function selectAs($column, string $alias): static { - $this->columns[] = $this->buildColumnName($column) . ' AS ' . $this->db->escapeIdentifiers($alias); + $column = $column instanceof Expression ? $column : $this->buildColumnName($column); + + $this->columns[] = $column . ' AS ' . $this->db->escapeIdentifiers($alias); return $this->asCrud('select'); } @@ -946,53 +950,6 @@ public function row(int $index, $type = PDO::FETCH_OBJ): mixed return $this->execute()->row($index, $type); } - /** - * Récupère une valeur spécifique - * - * @return list|mixed - */ - public function value(array|string $name) - { - $names = (array) $name; - $values = []; - - $row = $this->select($names)->first(PDO::FETCH_OBJ); - - foreach ($names as $v) { - if (is_string($v)) { - $values[] = $row->{$v} ?? null; - } - } - - return is_string($name) ? $values[0] : $values; - } - - /** - * Récupère plusieurs valeurs - * - * @return list - */ - public function values(array|string $name): array - { - $names = (array) $name; - $columns = []; - - $rows = $this->select($names)->all(PDO::FETCH_OBJ); - - foreach ($rows as $row) { - $values = []; - - foreach ($names as $v) { - if (is_string($v)) { - $values[$v] = $row->{$v} ?? null; - } - } - $columns[] = is_string($name) ? ($values[$name] ?? null) : $values; - } - - return $columns; - } - /** * Vérifie si des enregistrements existent */ @@ -1305,6 +1262,8 @@ public function reset(): static $this->lock = null; $this->uniqueBy = []; $this->updateColumns = []; + + $this->clearSelectedColumnsCache(); $this->db->setAliasedTables([]); return $this->asCrud('select'); diff --git a/src/Builder/Concerns/DataMethods.php b/src/Builder/Concerns/DataMethods.php index f579f1c..91451cf 100644 --- a/src/Builder/Concerns/DataMethods.php +++ b/src/Builder/Concerns/DataMethods.php @@ -14,13 +14,21 @@ use BlitzPHP\Contracts\Database\BuilderInterface; use BlitzPHP\Database\Query\Expression; use BlitzPHP\Database\Query\Result; +use BlitzPHP\Database\Utils; use Closure; +use Generator; +use PDO; /** * @mixin \BlitzPHP\Database\Builder\BaseBuilder */ trait DataMethods { + /** + * Cache des informations sur les colonnes sélectionnées + */ + protected array $selectedColumnsCache = []; + /* |-------------------------------------------------------------------------- | AGGREGATE METHODS @@ -107,7 +115,13 @@ public function aggregate(string $type, string $column) $alias = $type . '_value'; $column = $this->buildColumnName($column); - $result = $this->clone()->selectRaw(sprintf('%s(%s) AS %s', strtoupper($type), $column, $alias)); + $result = $this->clone()->selectRaw( + sprintf('%s(%s) AS %s', + strtoupper($type), + $column, + $this->db->escapeIdentifiers($alias) + ) + ); return $this->testMode ? $result->sql() : (float) ($result->value($alias) ?? 0); } @@ -303,4 +317,427 @@ protected function isRawExpression(mixed $value): bool { return $value instanceof Expression; } + + /* + |-------------------------------------------------------------------------- + | RECUPERATION DE DONNEES + |-------------------------------------------------------------------------- + */ + + /** + * Récupère une valeur spécifique + * + * @param array|string $column Colonne(s) à récupérer + * + * @return list|mixed La valeur demandée (valeur simple pour string, tableau associatif pour array) + * + * @example + * // Récupérer une valeur simple + * $email = $builder->where('id', 1)->value('email'); + * + * // Récupérer plusieurs valeurs + * ['name' => 'John', 'email' => 'john@example.com'] = $builder->where('id', 1)->value(['name', 'email']); + * + * // Avec une expression + * $builder->select(Expression('MAX(`batch`) AS max_batch')); + * $maxBatch = $builder->value('max_batch'); + */ + public function value(array|string $column) + { + $columns = (array) $column; + $isSingle = is_string($column); + + $this->ensureColumnsSelected($columns); + + $row = $this->first(PDO::FETCH_OBJ); + + if ($row === null) { + return $isSingle ? null : array_fill_keys($columns, null); + } + + $values = []; + foreach ($columns as $col) { + $values[] = $this->getColumnValue($row, $col); + } + + return $isSingle ? $values[0] : array_combine($columns, $values); + } + + /** + * Récupère plusieurs valeurs (pluck-like) + * + * @param array|string $column Colonne(s) à récupérer + * + * @return list Tableau des valeurs + * + * @example + * // Récupérer une liste de noms + * $names = $builder->orderBy('name')->values('name'); + * // ['John', 'Jane', 'Bob'] + * + * // Récupérer plusieurs colonnes + * $users = $builder->values(['id', 'name']); + * // [ + * // ['id' => 1, 'name' => 'John'], + * // ['id' => 2, 'name' => 'Jane'], + * // ] + */ + public function values(array|string $column): array + { + $columns = (array) $column; + $isSingle = is_string($column); + + $this->ensureColumnsSelected($columns); + + $rows = $this->all(PDO::FETCH_OBJ); + + if ($rows === []) { + return []; + } + + $results = []; + foreach ($rows as $row) { + if ($isSingle) { + $results[] = $this->getColumnValue($row, $column); + } else { + $values = []; + foreach ($columns as $col) { + $values[$col] = $this->getColumnValue($row, $col); + } + $results[] = $values; + } + } + + return $results; + } + + /** + * Récupère plusieurs valeurs par lots pour les grands jeux de résultats (support de la pagination) + */ + public function valuesChunked(array|string $column, int $chunkSize = 1000): Generator + { + $columns = (array) $column; + $isSingle = is_string($column); + + $this->ensureColumnsSelected($columns); + + $page = 1; + $originalLimit = $this->limit; + $originalOffset = $this->offset; + + try { + while (true) { + $chunk = $this->clone()->forPage($page, $chunkSize)->all(PDO::FETCH_OBJ); + + if ($chunk === []) { + break; + } + + foreach ($chunk as $row) { + if ($isSingle) { + yield $this->getColumnValue($row, $column); + } else { + $values = []; + foreach ($columns as $col) { + $values[$col] = $this->getColumnValue($row, $col); + } + yield $values; + } + } + + if (count($chunk) < $chunkSize) { + break; + } + + $page++; + } + } finally { + // Restaurer les limites originales + $this->limit = $originalLimit; + $this->offset = $originalOffset; + } + } + + /** + * + */ + public function pluck(string $column, ?string $key = null): array + { + $result = []; + + $this->ensureColumnsSelected([$column, $key]); + + $rows = $this->all(PDO::FETCH_OBJ); + + foreach ($rows as $row) { + $value = $this->getColumnValue($row, $column); + + if ($key !== null) { + $keyValue = $this->getColumnValue($row, $key); + $result[$keyValue] = $value; + } else { + $result[] = $value; + } + } + + return $result; + } + + /** + * S'assure que les colonnes demandées sont sélectionnées + * + * @param array $columns Colonnes à vérifier/ajouter + */ + protected function ensureColumnsSelected(array $columns): void + { + $missing = $this->getMissingColumns($columns); + + if ($missing !== []) { + $this->select($missing); + } + } + + /** + * Récupère les colonnes manquantes dans la sélection courante + * + * @param array $requestedColumns Colonnes demandées + * + * @return array Colonnes à ajouter + */ + protected function getMissingColumns(array $requestedColumns): array + { + $missing = []; + $selected = $this->getSelectedColumnsMap(); + + foreach ($requestedColumns as $column) { + if (!$this->isColumnSelected($column, $selected)) { + $missing[] = $column; + } + } + + return $missing; + } + + /** + * Récupère les informations sur les colonnes sélectionnées + * + * Structure retournée : + * - columns: Noms de colonnes exacts (inclut les noms simples extraits des colonnes avec table) + * - aliases: Alias définis explicitement (avec AS) + * - expressions: Alias des expressions SQL + * - raw: Colonnes brutes pour les cas particuliers + * + * @return array{ + * columns: array, + * aliases: array, + * expressions: array, + * raw: array + * } + */ + protected function getSelectedColumnsMap(): array + { + if ($this->selectedColumnsCache !== []) { + return $this->selectedColumnsCache; + } + + $info = [ + 'columns' => [], // Noms de colonnes exacts + 'aliases' => [], // Alias définis + 'expressions' => [], // Alias des expressions + 'raw' => [], // Colonnes brutes (pour débogage) + 'original' => [], // Mapping nom_normalisé => nom_original + ]; + + foreach ($this->columns as $column) { + if ($column instanceof Expression) { + // Analyser l'expression pour en extraire l'alias + $expression = (string) $column; + $alias = Utils::extractAlias($expression); + + if ($alias) { + $normalizedAlias = $this->db->normalizeIdentifier($alias); + $info['expressions'][$normalizedAlias] = $expression; + $info['columns'][$normalizedAlias] = true; + $info['original'][$normalizedAlias] = $alias; + } else { + // Expression sans alias, difficile à référencer + $info['raw'][] = $expression; + } + + continue; + } + + if (is_string($column)) { + $column = trim($column); + $info['raw'][] = $column; + + // Gestion des alias + if ($alias = Utils::extractAlias($column)) { + $normalizedAlias = $this->db->normalizeIdentifier($alias); + $info['aliases'][$normalizedAlias] = $column; + $info['columns'][$normalizedAlias] = true; + $info['original'][$normalizedAlias] = $alias; + continue; + } + + // Colonne simple ou avec table (users.name) + $normalizedColumn = $this->db->normalizeIdentifier($column); + $info['columns'][$normalizedColumn] = true; + $info['original'][$normalizedColumn] = $column; + + // Extraire le nom simple pour les colonnes avec point + if (str_contains($normalizedColumn, '.')) { + $parts = explode('.', $normalizedColumn); + $simpleName = end($parts); + $info['columns'][$simpleName] = true; + } + } + } + + return $this->selectedColumnsCache = $info; + } + + /** + * Vérifie si une colonne est déjà sélectionnée + * + * @param array{ + * columns: array, + * aliases: array, + * expressions: array + * } $selected Informations sur les colonnes sélectionnées + * + * @return bool + */ + protected function isColumnSelected(string $column, array $selected): bool + { + // Si * est sélectionné, tout est sélectionné + if (in_array('*', $this->columns, true)) { + return true; + } + + // Vérifier dans les colonnes exactes + if (isset($selected['columns'][$column])) { + return true; + } + + // Vérifier dans les alias + if (isset($selected['aliases'][$column])) { + return true; + } + + // Vérifier dans les expressions (alias) + if (isset($selected['expressions'][$column])) { + return true; + } + + // Vérifier si c'est un alias d'expression par correspondance de nom + foreach ($selected['expressions'] as $alias => $expr) { + if ($alias === $column) { + return true; + } + // Vérifier si la colonne recherchée correspond à l'alias + if (str_ends_with($alias, '_' . $column) || str_ends_with($column, '_' . $alias)) { + return true; + } + } + + // Vérifier les colonnes avec préfixe de table + if (str_contains($column, '.')) { + $parts = explode('.', $column); + $simpleName = end($parts); + if (isset($selected['columns'][$simpleName])) { + return true; + } + if (isset($selected['aliases'][$simpleName])) { + return true; + } + } + + return false; + } + + /** + * Récupère la valeur d'une colonne depuis un objet résultat + * + * @return mixed La valeur extraite ou null + */ + protected function getColumnValue(object $row, string $column): mixed + { + // Tentative 1: Accès direct par propriété + if (property_exists($row, $column)) { + return $row->{$column}; + } + + $selected = $this->getSelectedColumnsMap(); + + // Tentative 2: Colonne avec table (ex: users.name) + if (str_contains($column, '.')) { + $parts = explode('.', $column); + $simpleName = end($parts); + if (property_exists($row, $simpleName)) { + return $row->{$simpleName}; + } + + // Vérifier si le nom simple est un alias + if (isset($selected['aliases'][$simpleName]) && property_exists($row, $simpleName)) { + return $row->{$simpleName}; + } + } + + // Tentative 3: Cherchons dans les alias + foreach ($selected['aliases'] as $alias => $original) { + if ($alias === $column && property_exists($row, $alias)) { + return $row->{$alias}; + } + } + + // Tentative 4: Cherchons dans les expressions + foreach ($selected['expressions'] as $alias => $expr) { + if ($alias === $column && property_exists($row, $alias)) { + return $row->{$alias}; + } + } + + // Tentative 5: Cherchons une propriété avec le nom normalisé (sans guillemets) + $normalized = trim($column, '`"\''); + if (property_exists($row, $normalized)) { + return $row->{$normalized}; + } + + // Tentative 6: Chercher une propriété qui correspond au nom simple + // Utile pour les cas où le driver retourne des noms de colonnes avec préfixes + foreach (get_object_vars($row) as $prop => $value) { + // Correspondance exacte + if ($prop === $column) { + return $value; + } + + // Correspondance partielle (pour les alias avec suffixe. ex: "max_batch" et "batch") + if (str_ends_with($prop, '_' . $column)) { + return $value; + } + + // Correspondance avec underscore remplaçant le point, le driver peut retourner "tablename_columnname" + $normalized = str_replace('.', '_', $column); + if ($prop === $normalized) { + return $value; + } + + // Pour les alias d'expressions avec guillemets + $quotedColumn = '`' . $column . '`'; + if ($prop === $quotedColumn) { + return $value; + } + } + + return null; + } + + /** + * Vide le cache des colonnes selectionnées + */ + protected function clearSelectedColumnsCache(): void + { + $this->selectedColumnsCache = []; + } } diff --git a/src/Connection/BaseConnection.php b/src/Connection/BaseConnection.php index 3903558..08014e2 100644 --- a/src/Connection/BaseConnection.php +++ b/src/Connection/BaseConnection.php @@ -138,6 +138,11 @@ abstract class BaseConnection implements ConnectionInterface */ protected array $escapeCache = []; + /** + * Cache des colones et tables déséchappé + */ + protected array $unescapeCache = []; + /** * Requête SQL pour désactiver les contraintes */ @@ -817,6 +822,96 @@ public function isEscapedIdentifier(string $value): bool && str_ends_with($value, $this->escapeChar); } + /** + * Enlève les caractères d'échappement des identifiants SQL + * + * @param mixed $item Identifiant(s) à déséchapper + * + * @return mixed Identifiant(s) déséchappé(s) + * + * @example + * // Simple + * $unescaped = $db->unescapeIdentifiers('`users`.`name`'); + * // Résultat: 'users.name' + * + * // Tableau + * $unescaped = $db->unescapeIdentifiers(['`users`.`name`', '`email`']); + * // Résultat: ['users.name', 'email'] + * + * // Sans échappement + * $unescaped = $db->unescapeIdentifiers('users.name'); + * // Résultat: 'users.name' (inchangé) + */ + public function unescapeIdentifiers(mixed $item): mixed + { + if (is_array($item)) { + return array_map([$this, 'unescapeIdentifiers'], $item); + } + + if (! is_string($item)) { + return $item; + } + + if (! isset($this->unescapeCache[$item])) { + $this->unescapeCache[$item] = $this->doUnescapeIdentifiers($item); + } + + return $this->unescapeCache[$item]; + } + + /** + * Déséchappe un identifiant SQL + */ + protected function doUnescapeIdentifiers(string $item): string + { + // Si l'item est vide, on retourne tel quel + if ($item === '') { + return $item; + } + + // Si l'item contient un point, on traite chaque partie séparément + if (str_contains($item, '.')) { + $parts = explode('.', $item); + $unescapedParts = array_map([$this, 'unescapeIdentifier'], $parts); + return implode('.', $unescapedParts); + } + + // Sinon, on déséchappe l'identifiant simple + return $this->unescapeIdentifier($item); + } + + /** + * Déséchappe un identifiant simple + */ + protected function unescapeIdentifier(string $item): string + { + $item = trim($item); + + // Si l'identifiant est échappé, on enlève les caractères d'échappement + if ($this->isEscapedIdentifier($item)) { + $item = trim($item, $this->escapeChar); + } + + // Retirer les guillemets simples (pour les alias) + $item = trim($item, "'\""); + + return $item; + } + + /** + * Normalise un identifiant (enlève les guillemets et normalise le format) + */ + public function normalizeIdentifier(string $item): string + { + // Déséchapper d'abord + $normalized = $this->unescapeIdentifiers($item); + + // Normaliser les espaces + $normalized = preg_replace('/\s+/', ' ', $normalized); + + return trim($normalized); + } + /** * Crée le nom de la table avec son alias et le prefix des table de la base de données */ diff --git a/src/Utils.php b/src/Utils.php index 79e57f2..710133e 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -49,6 +49,20 @@ class Utils 'NOT EXISTS', 'EXISTS', ]; + public const SQL_KEYWORDS = [ + 'SELECT', 'DISTINCT', 'FROM', 'AS', + 'WHERE', 'AND', 'OR', + 'NOT IN', 'IN', 'IS NOT NULL', 'IS NULL', 'NOT LIKE', 'LIKE', 'NULL', 'NOT', + 'INNER JOIN', 'LEFT JOIN', 'NATURAL JOIN', 'RIGHT JOIN', 'JOIN', 'ON', + 'UNION', + 'GROUP BY', 'HAVING', + 'ORDER BY', 'ASC', 'DESC', 'LIMIT', 'OFFSET', + 'INSERT', 'INTO', 'VALUES', + 'UPDATE', + 'COUNT', 'MAX', 'MIN', 'AVG', 'SUM', + 'UPPER', 'LOWER', + ]; + private static ?string $expressionPattern = null; public static function isSqlFunction(string $value): bool @@ -120,9 +134,25 @@ public static function isAlias(string $value): bool /** * Extrait le nom de l'alias (avec ou sans "AS") */ - public static function extractAlias(string $value): string + public static function extractAlias(string $value): ?string { - return preg_replace('/^\s*AS\s+/i', '', trim($value)); + $value = trim($value); + + // Chercher "AS alias" à la fin de l'expression + if (preg_match('/\s+AS\s+([^\s]+)$/i', $value, $matches)) { + return trim($matches[1]); + } + + // Format sans AS: "... alias" (PostgreSQL style) + if (preg_match('/\s+([^\s]+)$/', $value, $matches)) { + $possibleAlias = trim($matches[1]); + // Vérifier que ce n'est pas un mot-clé SQL + if (! in_array(strtoupper($possibleAlias), static::SQL_KEYWORDS, true)) { + return $possibleAlias; + } + } + + return null; } /**