|
15 | 15 | use Cycle\Database\Injection\FragmentInterface; |
16 | 16 | use Cycle\Database\Injection\Parameter; |
17 | 17 | use Cycle\Database\Injection\ParameterInterface; |
| 18 | +use Cycle\Database\Query\ConflictAction; |
| 19 | +use Cycle\Database\Query\OnConflict; |
18 | 20 | use Cycle\Database\Query\QueryParameters; |
19 | 21 |
|
20 | 22 | abstract class Compiler implements CompilerInterface |
@@ -109,6 +111,9 @@ protected function fragment( |
109 | 111 | case self::INSERT_QUERY: |
110 | 112 | return $this->insertQuery($params, $q, $tokens); |
111 | 113 |
|
| 114 | + case self::UPSERT_QUERY: |
| 115 | + return $this->upsertQuery($params, $q, $tokens); |
| 116 | + |
112 | 117 | case self::SELECT_QUERY: |
113 | 118 | if ($nestedQuery) { |
114 | 119 | if ($fragment->getPrefix() !== null) { |
@@ -169,6 +174,121 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens |
169 | 174 | ); |
170 | 175 | } |
171 | 176 |
|
| 177 | + /** |
| 178 | + * Compile UPSERT (INSERT ... ON CONFLICT ...) for Postgres/SQLite-compatible dialects. |
| 179 | + * |
| 180 | + * @param array{ |
| 181 | + * table: non-empty-string, |
| 182 | + * columns: list<non-empty-string>, |
| 183 | + * values: list<mixed>, |
| 184 | + * onConflict: OnConflict, |
| 185 | + * } $tokens |
| 186 | + * |
| 187 | + * @return non-empty-string |
| 188 | + */ |
| 189 | + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string |
| 190 | + { |
| 191 | + $onConflict = $this->requireOnConflict($tokens); |
| 192 | + |
| 193 | + if ($tokens['columns'] === []) { |
| 194 | + throw new CompilerException('Upsert query must define at least one column.'); |
| 195 | + } |
| 196 | + |
| 197 | + $target = $onConflict->getTarget(); |
| 198 | + if ($target === []) { |
| 199 | + throw new CompilerException('Upsert query must define a conflict target.'); |
| 200 | + } |
| 201 | + |
| 202 | + $values = []; |
| 203 | + foreach ($tokens['values'] as $value) { |
| 204 | + $values[] = $this->value($params, $q, $value); |
| 205 | + } |
| 206 | + |
| 207 | + $head = \sprintf( |
| 208 | + 'INSERT INTO %s (%s) VALUES %s ON CONFLICT (%s)', |
| 209 | + $this->name($params, $q, $tokens['table'], true), |
| 210 | + $this->columns($params, $q, $tokens['columns']), |
| 211 | + \implode(', ', $values), |
| 212 | + $this->columns($params, $q, $target), |
| 213 | + ); |
| 214 | + |
| 215 | + if ($onConflict->getAction() === ConflictAction::Nothing) { |
| 216 | + return $head . ' DO NOTHING'; |
| 217 | + } |
| 218 | + |
| 219 | + $updates = $this->upsertUpdateClause( |
| 220 | + $params, |
| 221 | + $q, |
| 222 | + $tokens['columns'], |
| 223 | + $target, |
| 224 | + $onConflict->getUpdate(), |
| 225 | + 'EXCLUDED', |
| 226 | + ); |
| 227 | + |
| 228 | + return $head . ' DO UPDATE SET ' . $updates; |
| 229 | + } |
| 230 | + |
| 231 | + /** |
| 232 | + * @psalm-assert OnConflict $tokens['onConflict'] |
| 233 | + */ |
| 234 | + protected function requireOnConflict(array $tokens): OnConflict |
| 235 | + { |
| 236 | + $onConflict = $tokens['onConflict'] ?? null; |
| 237 | + $onConflict instanceof OnConflict or throw new CompilerException( |
| 238 | + 'Upsert query requires onConflict state to be configured.', |
| 239 | + ); |
| 240 | + |
| 241 | + return $onConflict; |
| 242 | + } |
| 243 | + |
| 244 | + /** |
| 245 | + * Build the column-assignment list for a DO UPDATE / ON DUPLICATE KEY UPDATE clause. |
| 246 | + * |
| 247 | + * @param list<string> $insertedColumns Columns from the INSERT column list. |
| 248 | + * @param list<string> $target Conflict target columns (excluded from auto-update list). |
| 249 | + * @param list<string>|array<string, mixed>|null $update Update spec (null = all, list = subset, map = expressions). |
| 250 | + * @param string $sourceAlias Pseudo-table name for source row (EXCLUDED, new_row, source). |
| 251 | + * @param string|null $targetAlias If non-null, qualifies each LHS column with `<targetAlias>.col` (used by MERGE). |
| 252 | + * |
| 253 | + * @psalm-return non-empty-string |
| 254 | + */ |
| 255 | + protected function upsertUpdateClause( |
| 256 | + QueryParameters $params, |
| 257 | + Quoter $q, |
| 258 | + array $insertedColumns, |
| 259 | + array $target, |
| 260 | + null|array $update, |
| 261 | + string $sourceAlias, |
| 262 | + ?string $targetAlias = null, |
| 263 | + ): string { |
| 264 | + $source = $this->quoteIdentifier($sourceAlias); |
| 265 | + $targetPrefix = $targetAlias !== null ? $this->quoteIdentifier($targetAlias) . '.' : ''; |
| 266 | + |
| 267 | + if ($update === null) { |
| 268 | + $columns = \array_values(\array_diff($insertedColumns, $target)); |
| 269 | + $columns === [] and $columns = $insertedColumns; |
| 270 | + |
| 271 | + return $this->upsertAssignmentsFromSource($params, $q, $columns, $source, $targetPrefix); |
| 272 | + } |
| 273 | + |
| 274 | + if (\array_is_list($update)) { |
| 275 | + /** @var list<string> $update */ |
| 276 | + return $this->upsertAssignmentsFromSource($params, $q, $update, $source, $targetPrefix); |
| 277 | + } |
| 278 | + |
| 279 | + $parts = []; |
| 280 | + foreach ($update as $column => $value) { |
| 281 | + $parts[] = \sprintf( |
| 282 | + '%s%s = %s', |
| 283 | + $targetPrefix, |
| 284 | + $this->name($params, $q, $column), |
| 285 | + $this->value($params, $q, $value), |
| 286 | + ); |
| 287 | + } |
| 288 | + |
| 289 | + return \implode(', ', $parts); |
| 290 | + } |
| 291 | + |
172 | 292 | /** |
173 | 293 | * @psalm-return non-empty-string |
174 | 294 | */ |
@@ -611,6 +731,29 @@ protected function compileJsonOrderBy(string $path): string|FragmentInterface |
611 | 731 | return $path; |
612 | 732 | } |
613 | 733 |
|
| 734 | + /** |
| 735 | + * @param list<string> $columns |
| 736 | + * |
| 737 | + * @psalm-return non-empty-string |
| 738 | + */ |
| 739 | + private function upsertAssignmentsFromSource( |
| 740 | + QueryParameters $params, |
| 741 | + Quoter $q, |
| 742 | + array $columns, |
| 743 | + string $quotedSourceAlias, |
| 744 | + string $targetPrefix, |
| 745 | + ): string { |
| 746 | + $parts = \array_map( |
| 747 | + function (string $column) use ($params, $q, $quotedSourceAlias, $targetPrefix) { |
| 748 | + $name = $this->name($params, $q, $column); |
| 749 | + return \sprintf('%s%s = %s.%s', $targetPrefix, $name, $quotedSourceAlias, $name); |
| 750 | + }, |
| 751 | + $columns, |
| 752 | + ); |
| 753 | + |
| 754 | + return \implode(', ', $parts); |
| 755 | + } |
| 756 | + |
614 | 757 | private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string |
615 | 758 | { |
616 | 759 | $operator = $in ? 'IN' : 'NOT IN'; |
|
0 commit comments