From 98e39beaf93959d43f432848caf9720cea77c69d Mon Sep 17 00:00:00 2001 From: dadansatria Date: Fri, 2 Jan 2026 23:36:10 +0700 Subject: [PATCH 1/5] Fix #1142: upsert to use first matching unique constraint --- CHANGELOG.md | 2 ++ src/QueryBuilder/AbstractDMLQueryBuilder.php | 36 ++++++-------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de0a23124..17608d961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.0.1 under development +- Bug #1142: Fix `upsert` to use only the first matching unique constraint instead of merging columns from multiple + unique constraints, which caused invalid `ON CONFLICT` clause generation in PostgreSQL (@ordivo-056) - Bug #1127: Fix `AbstractSchema::hasTable()` and `AbstractSchema::hasView()` methods to support names quoted with curly brackets `{{%table}}` (@batyrmastyr) diff --git a/src/QueryBuilder/AbstractDMLQueryBuilder.php b/src/QueryBuilder/AbstractDMLQueryBuilder.php index db83ce010..bfc8254db 100644 --- a/src/QueryBuilder/AbstractDMLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDMLQueryBuilder.php @@ -537,43 +537,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. * - * @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 []; } } From 45fcfa94e70290549ede0006e997997544e691af Mon Sep 17 00:00:00 2001 From: dadansatria Date: Fri, 2 Jan 2026 23:42:29 +0700 Subject: [PATCH 2/5] update changelog username --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17608d961..be68c199e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 2.0.1 under development - Bug #1142: Fix `upsert` to use only the first matching unique constraint instead of merging columns from multiple - unique constraints, which caused invalid `ON CONFLICT` clause generation in PostgreSQL (@ordivo-056) + unique constraints, which caused invalid `ON CONFLICT` clause generation in PostgreSQL (@dadansatria) - Bug #1127: Fix `AbstractSchema::hasTable()` and `AbstractSchema::hasView()` methods to support names quoted with curly brackets `{{%table}}` (@batyrmastyr) From 13c13c3704ec890669b559df83a0bd9d95016579 Mon Sep 17 00:00:00 2001 From: dadansatria Date: Sat, 3 Jan 2026 11:47:53 +0700 Subject: [PATCH 3/5] Add constraintColumns parameter to upsert methods for ON CONFLICT clause --- CHANGELOG.md | 6 ++++-- src/Command/AbstractCommand.php | 11 +++++++---- src/Command/CommandInterface.php | 9 +++++++++ src/Debug/CommandInterfaceProxy.php | 3 +++ src/QueryBuilder/AbstractDMLQueryBuilder.php | 13 ++++++++++++- src/QueryBuilder/AbstractQueryBuilder.php | 6 ++++-- src/QueryBuilder/DMLQueryBuilderInterface.php | 6 ++++++ 7 files changed, 45 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be68c199e..48d97465f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ## 2.0.1 under development -- Bug #1142: Fix `upsert` to use only the first matching unique constraint instead of merging columns from multiple - unique constraints, which caused invalid `ON CONFLICT` clause generation in PostgreSQL (@dadansatria) +- 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 bfc8254db..4bdebe12b 100644 --- a/src/QueryBuilder/AbstractDMLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDMLQueryBuilder.php @@ -149,6 +149,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 +160,7 @@ public function upsertReturning( string $table, array|QueryInterface $insertColumns, array|bool $updateColumns = true, + ?array $constraintColumns = null, ?array $returnColumns = null, array &$params = [], ): string { @@ -474,6 +476,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 +489,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 +497,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)]; 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; From f3222cd034fd39398a3730250eab5cb8efb2334c Mon Sep 17 00:00:00 2001 From: dadansatria <11630065+dadansatria@users.noreply.github.com> Date: Sat, 3 Jan 2026 04:48:49 +0000 Subject: [PATCH 4/5] Apply PHP CS Fixer and Rector changes (CI) --- src/QueryBuilder/AbstractDMLQueryBuilder.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/QueryBuilder/AbstractDMLQueryBuilder.php b/src/QueryBuilder/AbstractDMLQueryBuilder.php index 4bdebe12b..5fcd19a84 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; From 677079ac9de342a5a46048477995ac6b77d6e720 Mon Sep 17 00:00:00 2001 From: Sergei Tigrov Date: Tue, 6 Jan 2026 19:28:59 +0700 Subject: [PATCH 5/5] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/QueryBuilder/AbstractDMLQueryBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/QueryBuilder/AbstractDMLQueryBuilder.php b/src/QueryBuilder/AbstractDMLQueryBuilder.php index 5fcd19a84..c245a417b 100644 --- a/src/QueryBuilder/AbstractDMLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDMLQueryBuilder.php @@ -550,8 +550,8 @@ protected function getNormalizedColumnNames(array $columns): array * * @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 of the first matching constraint. */