diff --git a/spec/Builder/Binding.spec.php b/spec/Builder/Binding.spec.php index 4ccc2c7..f401bcb 100644 --- a/spec/Builder/Binding.spec.php +++ b/spec/Builder/Binding.spec.php @@ -20,8 +20,9 @@ $collection->add(true); $collection->add(null); - expect($collection->count())->toBe(4); - expect($collection->getOrdered())->toBe(['value1', 123, true, null]); + // le null n'est pas pris en charge par le binding + expect($collection->count())->toBe(3); + expect($collection->getOrdered())->toBe(['value1', 123, true]); }); it(": BindingCollection ajout nommé", function() { diff --git a/src/Builder/BaseBuilder.php b/src/Builder/BaseBuilder.php index 8246c8b..44636a7 100644 --- a/src/Builder/BaseBuilder.php +++ b/src/Builder/BaseBuilder.php @@ -488,6 +488,8 @@ public function set($key, $value = ''): static foreach ($key as $k => $v) { if ($v instanceof Expression) { $this->values[$k] = $v; + } elseif ($v === null) { + $this->values[$k] = null; } else { $this->values[$k] = $v; $this->bindings->add($v, 'values'); @@ -643,6 +645,107 @@ public function update(array|object $data = []) }); } + /** + * Met à jour plusieurs enregistrements en une seule requête + * + * @param list $data Tableau de données à mettre à jour, où chaque élément est un tableau associatif + * @param array|Expression|string $constraints Colonne utilisée pour identifier les enregistrements à mettre à jour (par défaut 'id') + * @param int $chunkSize Taille des lots pour le traitement + * + * @return int|string Nombre de lignes affectées ou la requête SQL en mode test + * + * @throws BadMethodCallException + * @throws InvalidArgumentException + * + * @example + * // Mise à jour simple + * $builder->bulkUpdate([ + * ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + * ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'], + * ]); + * + * // Mise à jour avec colonne personnalisée + * $builder->bulkUpdate([ + * ['code' => 'ABC', 'price' => 100], + * ['code' => 'DEF', 'price' => 150], + * ], 'code'); + */ + public function bulkUpdate(array $data, string $column = 'id', int $chunkSize = 100): int|string + { + if ($data === []) { + return 0; + } + + // Vérifier la structure des données + $firstRow = reset($data); + if (!is_array($firstRow)) { + throw new InvalidArgumentException('Each row must be an associative array.'); + } + + // Vérifier que la colonne d'identification existe dans chaque ligne + foreach ($data as $index => $row) { + if (!array_key_exists($column, $row)) { + throw new InvalidArgumentException( + "Column '{$column}' not found in row at index {$index}. Each row must contain the identifier column." + ); + } + } + + // Extraire les colonnes à mettre à jour (toutes sauf la colonne d'identification) + $updateColumns = array_diff(array_keys($firstRow), [$column]); + + if (empty($updateColumns)) { + return 0; // Rien à mettre à jour + } + + // Traitement par lots + $totalAffected = 0; + $chunks = array_chunk($data, $chunkSize); + $allSql = []; + + $callback = function() use ($chunks, $column, $updateColumns, &$totalAffected, &$allSql) { + foreach ($chunks as $chunk) { + $sql = $this->compiler->compileBulkUpdate($this, $chunk, $column, $updateColumns); + + if ($this->testMode) { + $allSql[] = $sql; + } else { + $bindings = $this->buildBulkUpdateBindings($chunk, $column, $updateColumns); + $totalAffected += $this->db->affectingStatement($sql, $bindings); + } + } + + return [$allSql, $totalAffected]; + }; + + [$allSql, $totalAffected] = $this->db->transaction($callback); + + return $this->testMode ? implode('; ', $allSql) : $totalAffected; + } + + /** + * Construit les bindings pour une requête bulk update + */ + protected function buildBulkUpdateBindings(array $chunk, string $column, array $updateColumns): array + { + $bindings = []; + + foreach ($updateColumns as $updateColumn) { + foreach ($chunk as $row) { + $value = $row[$updateColumn]; + if (!($value instanceof Expression) && $value !== null) { + $bindings[] = $value; + } + $bindings[] = $row[$column]; + } + } + + $ids = array_column($chunk, $column); + $bindings = array_merge($bindings, $ids); + + return $bindings; + } + /** * Exécute une requête de remplacement. * @@ -1134,9 +1237,16 @@ public function getBindings(): array default => [], // Fallback à tous }; - return $types === null - ? [] - : $this->db->prepareBindings($this->bindings->getOrdered($types)); + if($types === null) { + return []; + } + + $bindings = $this->bindings->getOrdered($types); + $bindings = array_filter($bindings, function($binding) { + return $binding !== '__NULL__' && !($binding instanceof Expression); + }); + + return $this->db->prepareBindings(array_values($bindings)); } /** diff --git a/src/Builder/BindingCollection.php b/src/Builder/BindingCollection.php index 74cd998..791f10b 100644 --- a/src/Builder/BindingCollection.php +++ b/src/Builder/BindingCollection.php @@ -63,6 +63,20 @@ public function add(mixed $value, string $type = 'where', ?int $pdoType = null): throw new InvalidArgumentException("Type de binding invalide: {$type}"); } + if ($value === null) { + $this->bindings[$type][] = '__NULL__'; + $this->types[$type][] = PDO::PARAM_NULL; + + return $this; + } + + if ($value instanceof Expression) { + $this->bindings[$type][] = $value; + $this->types[$type][] = null; + + return $this; + } + $this->bindings[$type][] = $value; $this->types[$type][] = $pdoType ?? $this->guessType($value); @@ -134,7 +148,12 @@ public function getOrdered(array $contexts = []): array foreach ($contexts as $context) { if (! empty($this->bindings[$context])) { - array_push($result, ...$this->bindings[$context]); + foreach ($this->bindings[$context] as $binding) { + if ($binding === '__NULL__' || $binding instanceof Expression) { + continue; + } + $result[] = $binding; + } } } @@ -142,26 +161,32 @@ public function getOrdered(array $contexts = []): array } /** - * Récupère tous les types dans l'ordre - * - * @param list $types + * Récupère les types PDO dans l'ordre + * + * @param list $contexts * * @return list */ - public function getTypesOrdered(array $types = []): array + public function getTypesOrdered(array $contexts = []): array { - if ($types === []) { - $types = self::TYPES; + if ($contexts === []) { + $contexts = self::TYPES; } $result = []; - - foreach ($types as $type) { - if (! empty($this->types[$type])) { - array_push($result, ...$this->types[$type]); + foreach ($contexts as $context) { + if (!empty($this->types[$context])) { + foreach ($this->types[$context] as $index => $type) { + $binding = $this->bindings[$context][$index] ?? null; + if ($binding === '__NULL__') { + $result[] = PDO::PARAM_NULL; + } elseif ($type !== null) { + $result[] = $type; + } + } } } - + return $result; } @@ -178,11 +203,11 @@ public function has(string $context): bool */ public function count(?string $context = null): int { - if ($context !== null) { - return count($this->bindings[$context] ?? []); - } + $context = $context === null ? [] : [$context]; + + $bindings = $this->getOrdered($context); - return array_sum(array_map('count', $this->bindings)); + return count($bindings); } /** diff --git a/src/Builder/Compilers/QueryCompiler.php b/src/Builder/Compilers/QueryCompiler.php index 9576272..db7d55d 100644 --- a/src/Builder/Compilers/QueryCompiler.php +++ b/src/Builder/Compilers/QueryCompiler.php @@ -149,6 +149,52 @@ public function compileUpdate(BaseBuilder $builder): string return $this->compileUpdateStandard($builder); } + /** + * Compile une requête de mise à jour en masse + * + * @param array $chunk Données du lot + * @param string $column Colonne d'identification + * @param array $updateColumns Colonnes à mettre à jour + */ + public function compileBulkUpdate(BaseBuilder $builder, array $chunk, string $column, array $updateColumns): string + { + $table = $this->db->escapeIdentifiers($builder->getTable()); + $columnEscaped = $this->db->escapeIdentifiers($column); + + // Construction du CASE WHEN pour chaque colonne à mettre à jour + $updateParts = []; + foreach ($updateColumns as $updateColumn) { + $caseStatement = $this->buildCaseStatement($chunk, $updateColumn, $column); + $updateParts[] = $this->db->escapeIdentifiers($updateColumn) . ' = ' . $caseStatement; + } + + // Construction de la clause WHERE IN + $ids = array_column($chunk, $column); + $placeholders = implode(', ', array_fill(0, count($ids), '?')); + + return "UPDATE {$table} SET " . implode(', ', $updateParts) . " WHERE {$columnEscaped} IN ({$placeholders})"; + } + + /** + * Construit une clause CASE WHEN pour une colonne spécifique + * + * @param array $chunk Données du lot + * @param string $updateColumn Colonne à mettre à jour + * @param string $column Colonne d'identification + */ + protected function buildCaseStatement(array $chunk, string $updateColumn, string $column): string + { + $cases = []; + $column = $this->db->escapeIdentifiers($column); + + foreach ($chunk as $row) { + $value = $this->wrapValue($row[$updateColumn]); + $cases[] = "WHEN {$column} = ? THEN {$value}"; + } + + return "CASE " . implode(' ', $cases) . " END"; + } + /** * Compilation standard sans jointure */ @@ -434,6 +480,9 @@ protected function compileWhere(array $where): string $column = $this->db->escapeIdentifiers($where['column']); $operator = $this->translateOperator($where['operator']); + if (isset($where['value']) && $where['value'] === null) { + return "{$column} IS NULL"; + } if (isset($where['value']) && $where['value'] instanceof Expression) { return "{$column} {$operator} {$where['value']}"; } @@ -441,9 +490,28 @@ protected function compileWhere(array $where): string return "{$column} {$operator} ?"; case 'in': - $column = $this->db->escapeIdentifiers($where['column']); - $placeholders = implode(', ', array_fill(0, count($where['values']), '?')); - + $column = $this->db->escapeIdentifiers($where['column']); + $hasNull = false; + $values = []; + + foreach ($where['values'] as $value) { + if ($value === null) { + $hasNull = true; + } else { + $values[] = $value; + } + } + + if ($values === [] && $hasNull) { + return "{$column} IS NULL"; + } + + if ($hasNull) { + $placeholders = implode(', ', array_fill(0, count($values), '?')); + return "({$column} IN ({$placeholders}) OR {$column} IS NULL)"; + } + + $placeholders = implode(', ', array_fill(0, count($values), '?')); return "{$column} {$where['operator']} ({$placeholders})"; case 'insub': @@ -634,6 +702,10 @@ protected function wrapValue($value): string if ($value instanceof Expression) { return (string) $value; } + + if ($value === null) { + return 'NULL'; + } return '?'; } diff --git a/src/Query/Result.php b/src/Query/Result.php index 842b37c..a1d0ea2 100644 --- a/src/Query/Result.php +++ b/src/Query/Result.php @@ -219,6 +219,11 @@ public function columnData(): array */ public function get(int|string $type = PDO::FETCH_OBJ): array { + $type = match($type) { + 'array' => PDO::FETCH_ASSOC, + 'object' => PDO::FETCH_OBJ, + default => $type, + }; $data = is_string($type) ? $this->resultClass($type) : $this->result($type); $this->details['num_rows'] = count($data);