diff --git a/CHANGELOG.md b/CHANGELOG.md index de0a23124..48d97465f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 2.0.1 under development +- Chg #1142: Add `$constraintColumns` parameter to `upsert()`, `upsertReturning()`, and `upsertReturningPks()` methods + to allow explicit specification of columns for the `ON CONFLICT` clause. When `null` (default), the primary key or + the first matching unique constraint is used. This fixes invalid `ON CONFLICT` clause generation when a table has + multiple separate unique constraints (@dadansatria) - Bug #1127: Fix `AbstractSchema::hasTable()` and `AbstractSchema::hasView()` methods to support names quoted with curly brackets `{{%table}}` (@batyrmastyr) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 84cf33dca..b6b76bc88 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -499,9 +499,10 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): static { $params = []; - $sql = $this->getQueryBuilder()->upsert($table, $insertColumns, $updateColumns, $params); + $sql = $this->getQueryBuilder()->upsert($table, $insertColumns, $updateColumns, $constraintColumns, $params); return $this->setSql($sql)->bindValues($params); } @@ -509,16 +510,17 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, ): array { if ($returnColumns === []) { - $this->upsert($table, $insertColumns, $updateColumns)->execute(); + $this->upsert($table, $insertColumns, $updateColumns, $constraintColumns)->execute(); return []; } $params = []; $sql = $this->getQueryBuilder() - ->upsertReturning($table, $insertColumns, $updateColumns, $returnColumns, $params); + ->upsertReturning($table, $insertColumns, $updateColumns, $constraintColumns, $returnColumns, $params); $this->setSql($sql)->bindValues($params); @@ -530,9 +532,10 @@ public function upsertReturningPks( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): array { $primaryKeys = $this->db->getSchema()->getTableSchema($table)?->getPrimaryKey() ?? []; - return $this->upsertReturning($table, $insertColumns, $updateColumns, $primaryKeys); + return $this->upsertReturning($table, $insertColumns, $updateColumns, $constraintColumns, $primaryKeys); } public function withDbTypecasting(bool $dbTypecasting = true): static diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index 397e30f02..4dc3bf4a4 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -894,6 +894,8 @@ public function update( * @param array|bool $updateColumns The column data (name => value) to update if it already exists. * If `true` is passed, the column data will be updated to match the insert column data. * If `false` is passed, no update will be performed if the column data already exist. + * @param string[]|null $constraintColumns The column names to use for the `ON CONFLICT` clause. If `null`, + * the primary key or the first matching unique constraint will be used. * * @throws Exception * @throws InvalidConfigException @@ -910,6 +912,7 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): static; /** @@ -924,6 +927,8 @@ public function upsert( * @param array|bool $updateColumns The column data (name => value) to update if it already exists. * If `true` is passed, the column data will be updated to match the insert column data. * If `false` is passed, no update will be performed if the column data already exist. + * @param string[]|null $constraintColumns The column names to use for the `ON CONFLICT` clause. If `null`, + * the primary key or the first matching unique constraint will be used. * @param string[]|null $returnColumns The column names to return values from. `null` means all columns. * * @throws Exception @@ -941,6 +946,7 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, ): array; @@ -956,6 +962,8 @@ public function upsertReturning( * @param array|bool $updateColumns The column data (name => value) to update if it already exists. * If `true` is passed, the column data will be updated to match the insert column data. * If `false` is passed, no update will be performed if the column data already exist. + * @param string[]|null $constraintColumns The column names to use for the `ON CONFLICT` clause. If `null`, + * the primary key or the first matching unique constraint will be used. * * @throws Exception * @throws InvalidConfigException @@ -971,6 +979,7 @@ public function upsertReturningPks( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): array; /** diff --git a/src/Debug/CommandInterfaceProxy.php b/src/Debug/CommandInterfaceProxy.php index 1e18f1078..47e7d9e56 100644 --- a/src/Debug/CommandInterfaceProxy.php +++ b/src/Debug/CommandInterfaceProxy.php @@ -498,6 +498,7 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): static { return new self($this->decorated->{__FUNCTION__}(...func_get_args()), $this->collector); } @@ -506,6 +507,7 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, ): array { /** @psalm-var array */ @@ -516,6 +518,7 @@ public function upsertReturningPks( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ): array { /** @var array */ return $this->decorated->{__FUNCTION__}(...func_get_args()); diff --git a/src/QueryBuilder/AbstractDMLQueryBuilder.php b/src/QueryBuilder/AbstractDMLQueryBuilder.php index db83ce010..c245a417b 100644 --- a/src/QueryBuilder/AbstractDMLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDMLQueryBuilder.php @@ -25,12 +25,9 @@ use function array_combine; use function array_diff; use function array_fill_keys; -use function array_filter; use function array_key_exists; use function array_keys; use function array_map; -use function array_merge; -use function array_unique; use function array_values; use function count; use function get_object_vars; @@ -149,6 +146,7 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, array &$params = [], ): string { throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.'); @@ -159,6 +157,7 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, array &$params = [], ): string { @@ -474,6 +473,8 @@ protected function prepareUpsertSets( * Prepare column names and constraints for "upsert" operation. * * @param Index[] $constraints + * @param string[]|null $constraintColumns The column names to use for the conflict clause. If `null`, + * the primary key or the first matching unique constraint will be used. * * @psalm-param array|QueryInterface $insertColumns * @@ -485,6 +486,7 @@ protected function prepareUpsertColumns( array|QueryInterface $insertColumns, array|bool $updateColumns, array &$constraints = [], + ?array $constraintColumns = null, ): array { if ($insertColumns instanceof QueryInterface) { $insertNames = $this->getQueryColumnNames($insertColumns); @@ -492,7 +494,13 @@ protected function prepareUpsertColumns( $insertNames = $this->getNormalizedColumnNames(array_keys($insertColumns)); } - $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints); + // Use provided constraint columns or auto-detect from table schema + if ($constraintColumns !== null) { + $uniqueNames = $this->getNormalizedColumnNames($constraintColumns); + $constraints = []; + } else { + $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints); + } if ($updateColumns === true) { return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)]; @@ -537,43 +545,29 @@ protected function getNormalizedColumnNames(array $columns): array } /** - * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.) - * for the named table removing constraints which didn't cover the specified column list. - * - * The column list will be unique by column names. + * Returns column names of the first constraint enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.) + * for the named table that is covered by the specified column list. * * @param string $name The table name, may contain schema name if any. Don't quote the table name. * @param string[] $columns Source column list. - * @param Index[] $indexes This parameter optionally receives a matched index list. - * The constraints will be unique by their column names. + * @param Index[] $indexes This parameter optionally receives the first matched index, + * or an empty array if no matching index is found. * - * @return string[] The column names. + * @return string[] The column names of the first matching constraint. */ private function getTableUniqueColumnNames(string $name, array $columns, array &$indexes = []): array { $indexes = $this->schema->getTableUniques($name); - $columnNames = []; - - // Remove all indexes which don't cover the specified column list. - $indexes = array_values( - array_filter( - $indexes, - static function (Index $index) use ($columns, &$columnNames): bool { - $result = empty(array_diff($index->columnNames, $columns)); - if ($result) { - $columnNames[] = $index->columnNames; - } - - return $result; - }, - ), - ); - - if (empty($columnNames)) { - return []; + // Find the first index that is fully covered by the specified column list. + foreach ($indexes as $index) { + if (empty(array_diff($index->columnNames, $columns))) { + $indexes = [$index]; + return $index->columnNames; + } } - return array_unique(array_merge(...$columnNames)); + $indexes = []; + return []; } } diff --git a/src/QueryBuilder/AbstractQueryBuilder.php b/src/QueryBuilder/AbstractQueryBuilder.php index 34db88f17..37a2a5e20 100644 --- a/src/QueryBuilder/AbstractQueryBuilder.php +++ b/src/QueryBuilder/AbstractQueryBuilder.php @@ -575,19 +575,21 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, array &$params = [], ): string { - return $this->dmlBuilder->upsert($table, $insertColumns, $updateColumns, $params); + return $this->dmlBuilder->upsert($table, $insertColumns, $updateColumns, $constraintColumns, $params); } public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, array &$params = [], ): string { - return $this->dmlBuilder->upsertReturning($table, $insertColumns, $updateColumns, $returnColumns, $params); + return $this->dmlBuilder->upsertReturning($table, $insertColumns, $updateColumns, $constraintColumns, $returnColumns, $params); } public function withTypecasting(bool $typecasting = true): static diff --git a/src/QueryBuilder/DMLQueryBuilderInterface.php b/src/QueryBuilder/DMLQueryBuilderInterface.php index 75d7e7a8c..66d86072e 100644 --- a/src/QueryBuilder/DMLQueryBuilderInterface.php +++ b/src/QueryBuilder/DMLQueryBuilderInterface.php @@ -226,6 +226,8 @@ public function update( * @param array|bool $updateColumns The column data (name => value) to update if they already exist. If `true` * is passed, the column data will be updated to match the insert column data. If `false` is passed, no update will * be performed if the column data already exist. + * @param string[]|null $constraintColumns The column names to use for the `ON CONFLICT` clause. If `null`, + * the primary key or the first matching unique constraint will be used. * @param array $params The binding parameters that will be generated by this method. They should be bound to the DB * command later. * @@ -243,6 +245,7 @@ public function upsert( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, array &$params = [], ): string; @@ -258,6 +261,8 @@ public function upsert( * @param array|bool $updateColumns The column data (name => value) to update if they already exist. If `true` * is passed, the column data will be updated to match the insert column data. If `false` is passed, no update will * be performed if the column data already exist. + * @param string[]|null $constraintColumns The column names to use for the `ON CONFLICT` clause. If `null`, + * the primary key or the first matching unique constraint will be used. * @param string[]|null $returnColumns The column names to return values from. `null` means all columns. * @param array $params The binding parameters that will be generated by this method. They should be bound to the DB * command later. @@ -274,6 +279,7 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, array &$params = [], ): string;