Skip to content

Commit 395a7ff

Browse files
authored
feat: Add InsertQuery::onConflict() method (#249)
1 parent 610d2cb commit 395a7ff

23 files changed

Lines changed: 1782 additions & 15 deletions

src/Driver/Compiler.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Cycle\Database\Injection\FragmentInterface;
1616
use Cycle\Database\Injection\Parameter;
1717
use Cycle\Database\Injection\ParameterInterface;
18+
use Cycle\Database\Query\ConflictAction;
19+
use Cycle\Database\Query\OnConflict;
1820
use Cycle\Database\Query\QueryParameters;
1921

2022
abstract class Compiler implements CompilerInterface
@@ -109,6 +111,9 @@ protected function fragment(
109111
case self::INSERT_QUERY:
110112
return $this->insertQuery($params, $q, $tokens);
111113

114+
case self::UPSERT_QUERY:
115+
return $this->upsertQuery($params, $q, $tokens);
116+
112117
case self::SELECT_QUERY:
113118
if ($nestedQuery) {
114119
if ($fragment->getPrefix() !== null) {
@@ -169,6 +174,121 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
169174
);
170175
}
171176

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+
172292
/**
173293
* @psalm-return non-empty-string
174294
*/
@@ -611,6 +731,29 @@ protected function compileJsonOrderBy(string $path): string|FragmentInterface
611731
return $path;
612732
}
613733

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+
614757
private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string
615758
{
616759
$operator = $in ? 'IN' : 'NOT IN';

src/Driver/CompilerCache.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Cycle\Database\Injection\Parameter;
1919
use Cycle\Database\Injection\ParameterInterface;
2020
use Cycle\Database\Injection\SubQuery;
21+
use Cycle\Database\Query\OnConflict;
2122
use Cycle\Database\Query\QueryInterface;
2223
use Cycle\Database\Query\QueryParameters;
2324
use Cycle\Database\Query\SelectQuery;
@@ -77,6 +78,23 @@ public function compile(QueryParameters $params, string $prefix, FragmentInterfa
7778
}
7879
}
7980

81+
if ($fragment->getType() === self::UPSERT_QUERY) {
82+
$tokens = $fragment->getTokens();
83+
84+
if (\count($tokens['values']) === 1) {
85+
$queryHash = $prefix . $this->hashUpsertQuery($params, $tokens);
86+
if (isset($this->cache[$queryHash])) {
87+
return $this->cache[$queryHash];
88+
}
89+
90+
return $this->cache[$queryHash] = $this->compiler->compile(
91+
new QueryParameters(),
92+
$prefix,
93+
$fragment,
94+
);
95+
}
96+
}
97+
8098
return $this->compiler->compile(
8199
$params,
82100
$prefix,
@@ -146,6 +164,23 @@ protected function hashInsertQuery(QueryParameters $params, array $tokens): stri
146164
return $hash;
147165
}
148166

167+
/**
168+
* @psalm-return non-empty-string
169+
*/
170+
protected function hashUpsertQuery(QueryParameters $params, array $tokens): string
171+
{
172+
$hash = 'u_' . $this->hashInsertQuery($params, $tokens);
173+
174+
$onConflict = $tokens['onConflict'] ?? null;
175+
if (!$onConflict instanceof OnConflict) {
176+
return $hash;
177+
}
178+
179+
// Driver-specific subclasses extend getCacheKey() to append their own fields
180+
// and push any embedded fragment parameters via $params.
181+
return $hash . '_oc' . $onConflict->getCacheKey($params);
182+
}
183+
149184
/**
150185
* @psalm-return non-empty-string
151186
*/

src/Driver/CompilerInterface.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface CompilerInterface
2525
public const DELETE_QUERY = 7;
2626
public const JSON_EXPRESSION = 8;
2727
public const SUBQUERY = 9;
28+
public const UPSERT_QUERY = 10;
2829
public const TOKEN_AND = '@AND';
2930
public const TOKEN_OR = '@OR';
3031
public const TOKEN_AND_NOT = '@AND NOT';

src/Driver/MySQL/MySQLCompiler.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
use Cycle\Database\Driver\Compiler;
1616
use Cycle\Database\Driver\MySQL\Injection\CompileJson;
1717
use Cycle\Database\Driver\Quoter;
18+
use Cycle\Database\Exception\CompilerException;
1819
use Cycle\Database\Injection\FragmentInterface;
1920
use Cycle\Database\Injection\Parameter;
21+
use Cycle\Database\Query\ConflictAction;
2022
use Cycle\Database\Query\QueryParameters;
2123

2224
/**
@@ -36,6 +38,58 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
3638
return parent::insertQuery($params, $q, $tokens);
3739
}
3840

41+
/**
42+
* Compile UPSERT as `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE col = <alias>.col`.
43+
* Requires MySQL 8.0.19+ (row-alias syntax). The alias defaults to
44+
* {@see MySQLOnConflict::DEFAULT_ROW_ALIAS}; customize via
45+
* {@see MySQLOnConflict::withRowAlias()} if it collides with a real column name.
46+
*/
47+
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
48+
{
49+
$onConflict = MySQLOnConflict::from($this->requireOnConflict($tokens));
50+
51+
if ($tokens['columns'] === []) {
52+
throw new CompilerException('Upsert query must define at least one column.');
53+
}
54+
55+
$values = [];
56+
foreach ($tokens['values'] as $value) {
57+
$values[] = $this->value($params, $q, $value);
58+
}
59+
60+
$rowAlias = $onConflict->getRowAlias();
61+
62+
$head = \sprintf(
63+
'INSERT INTO %s (%s) VALUES %s AS %s',
64+
$this->name($params, $q, $tokens['table'], true),
65+
$this->columns($params, $q, $tokens['columns']),
66+
\implode(', ', $values),
67+
$this->quoteIdentifier($rowAlias),
68+
);
69+
70+
if ($onConflict->getAction() === ConflictAction::Nothing) {
71+
// MySQL has no DO NOTHING — emulate with a no-op self-assignment on the
72+
// conflict-target column (or the first inserted column as a fallback).
73+
// Using the target column is the conventional idiom and is more predictable
74+
// for schemas where the first inserted column is unrelated to the conflict.
75+
$target = $onConflict->getTarget();
76+
$noopColumn = $target[0] ?? $tokens['columns'][0];
77+
$name = $this->name($params, $q, $noopColumn);
78+
return $head . ' ON DUPLICATE KEY UPDATE ' . \sprintf('%s = %s', $name, $name);
79+
}
80+
81+
$updates = $this->upsertUpdateClause(
82+
$params,
83+
$q,
84+
$tokens['columns'],
85+
$onConflict->getTarget(),
86+
$onConflict->getUpdate(),
87+
$rowAlias,
88+
);
89+
90+
return $head . ' ON DUPLICATE KEY UPDATE ' . $updates;
91+
}
92+
3993
/**
4094
*
4195
*
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver\MySQL;
6+
7+
use Cycle\Database\Exception\BuilderException;
8+
use Cycle\Database\Query\ConflictAction;
9+
use Cycle\Database\Query\OnConflict;
10+
use Cycle\Database\Query\QueryParameters;
11+
12+
/**
13+
* MySQL-specific conflict-resolution policy.
14+
*
15+
* Adds:
16+
* - {@see self::withRowAlias()} — customize the row alias used in
17+
* `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE col = <alias>.col`.
18+
* Default alias is {@see self::DEFAULT_ROW_ALIAS}. Customize only when it
19+
* collides with a real column name your update expressions reference.
20+
*
21+
* Note on target columns: MySQL's `ON DUPLICATE KEY UPDATE` fires on ANY matching
22+
* unique index, so the {@see self::target()} list does NOT drive which constraint
23+
* is checked. It IS however used by the compiler to compute the auto-update list
24+
* when {@see self::doUpdate()} is called without an explicit column list — target
25+
* columns are excluded from the auto-generated `SET col = new_row.col` clause,
26+
* matching Postgres/SQLite semantics.
27+
*/
28+
final class MySQLOnConflict extends OnConflict
29+
{
30+
public const DEFAULT_ROW_ALIAS = 'new_row';
31+
32+
/**
33+
* @param list<non-empty-string> $target
34+
* @param list<non-empty-string>|array<non-empty-string, mixed>|null $update
35+
* @param non-empty-string $rowAlias
36+
*/
37+
protected function __construct(
38+
array $target,
39+
ConflictAction $action,
40+
null|array $update,
41+
protected string $rowAlias = self::DEFAULT_ROW_ALIAS,
42+
) {
43+
parent::__construct($target, $action, $update);
44+
}
45+
46+
public static function from(OnConflict $options): static
47+
{
48+
if ($options instanceof self) {
49+
return $options;
50+
}
51+
52+
if ($options::class !== OnConflict::class) {
53+
throw new BuilderException(\sprintf(
54+
'Cannot narrow %s to %s. Use the base OnConflict, or %s directly.',
55+
$options::class,
56+
self::class,
57+
self::class,
58+
));
59+
}
60+
61+
return new self(
62+
target: $options->getTarget(),
63+
action: $options->getAction(),
64+
update: $options->getUpdate(),
65+
);
66+
}
67+
68+
/**
69+
* Set the row alias used in `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE`.
70+
*
71+
* @param non-empty-string $alias Must be a valid MySQL identifier.
72+
*/
73+
public function withRowAlias(string $alias): self
74+
{
75+
$alias === '' and throw new BuilderException('Row alias must not be empty.');
76+
77+
$clone = clone $this;
78+
$clone->rowAlias = $alias;
79+
return $clone;
80+
}
81+
82+
/**
83+
* @return non-empty-string
84+
*/
85+
public function getRowAlias(): string
86+
{
87+
return $this->rowAlias;
88+
}
89+
90+
public function getCacheKey(QueryParameters $params): string
91+
{
92+
return parent::getCacheKey($params) . 'RA' . $this->rowAlias;
93+
}
94+
}

0 commit comments

Comments
 (0)