Skip to content

Commit

Permalink
Postgres 17: Allow WHEN NOT MATCHED BY SOURCE actions in MERGE
Browse files Browse the repository at this point in the history
  • Loading branch information
sad-spirit committed Jan 13, 2025
1 parent c317e6e commit 6a3da91
Show file tree
Hide file tree
Showing 7 changed files with 68 additions and 16 deletions.
7 changes: 6 additions & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ The package now requires PHP 8.2+ and Postgres 12+.

### Added

* Tested on PHP 8.4 and Postgres 17
* Support for new syntax of Postgres 17
* SQL/JSON functions
* `json()` produces json values from text, bytea, json or jsonb values, represented by
Expand All @@ -24,14 +23,20 @@ The package now requires PHP 8.2+ and Postgres 12+.
* It is now possible to use `MERGE` statements in `WITH` clauses.
* Support `RETURNING` clause in `MERGE`, represented by `$returning` property of `Merge` class,
`merge_action()` construct that can be used in that clause, represented by `nodes\expressions\MergeAction`.
* Support `WHEN NOT MATCHED BY SOURCE` actions which operate on rows that exist in the target relation, but
not in the data source. Represented by a new `$matchedBySource` property of existing
`nodes\merge\MergeWhenMatched` class.
* `AT LOCAL` expression for converting a timestamp to session time zone, represented by
`nodes\expressions\AtLocalExpression`.
* Tested on PHP 8.4 and Postgres 17

### Changed
* Consistently follow Postgres 17 in what is considered a whitespace character: space, `\r`, `\n`, `\t`, `\v`, `\f`.
* Native `public readonly` properties are used in `nodes\Identifier`, `nodes\expressions\Constant`,
`nodes\expressions\Parameter`, and their subclasses instead of magic ones.
* Enums are used throughout package instead of string constants. Migration TBD.
* Added typehints for arguments and return values where not previously possible,
e.g. `self|string|ScalarExpression|null` for an argument of `nodes\WhereOrHavingClause::and()`.

### Removed

Expand Down
12 changes: 11 additions & 1 deletion src/sad_spirit/pg_builder/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -4419,6 +4419,16 @@ protected function MergeWhenClause(): nodes\merge\MergeWhenClause
}
$this->stream->expectKeyword(Keyword::MATCHED);

$matchedBySource = true;
if (!$matched && $this->stream->matchesKeywordSequence(Keyword::BY, [Keyword::SOURCE, Keyword::TARGET])) {
$this->stream->next();
// "BY TARGET" is noise, "BY SOURCE" should generate MergeWhenMatched instead of MergeWhenNotMatched
if (Keyword::SOURCE === $this->stream->next()->getKeyword()) {
$matched = true;
$matchedBySource = false;
}
}

if (Keyword::AND !== $this->stream->getKeyword()) {
$condition = null;
} else {
Expand All @@ -4428,7 +4438,7 @@ protected function MergeWhenClause(): nodes\merge\MergeWhenClause

$this->stream->expectKeyword(Keyword::THEN);
return $matched
? new nodes\merge\MergeWhenMatched($condition, $this->MergeWhenMatchedAction())
? new nodes\merge\MergeWhenMatched($condition, $this->MergeWhenMatchedAction(), $matchedBySource)
: new nodes\merge\MergeWhenNotMatched($condition, $this->MergeWhenNotMatchedAction());
}

Expand Down
2 changes: 1 addition & 1 deletion src/sad_spirit/pg_builder/SqlBuilderWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -1514,7 +1514,7 @@ public function walkMergeValues(nodes\merge\MergeValues $clause): string
public function walkMergeWhenMatched(nodes\merge\MergeWhenMatched $clause): string
{
$lines = [
'when matched'
($clause->matchedBySource ? 'when matched' : 'when not matched by source')
. (null === $clause->condition ? '' : ' and ' . $clause->condition->dispatch($this))
. ' then'
];
Expand Down
23 changes: 21 additions & 2 deletions src/sad_spirit/pg_builder/nodes/merge/MergeWhenMatched.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,47 @@

use sad_spirit\pg_builder\exceptions\InvalidArgumentException;
use sad_spirit\pg_builder\Node;
use sad_spirit\pg_builder\nodes\ScalarExpression;
use sad_spirit\pg_builder\TreeWalker;

/**
* Represents a "WHEN MATCHED" clause of MERGE statement
* Represents a `WHEN MATCHED` / `WHEN NOT MATCHED BY SOURCE` clause of `MERGE` statement
*
* @property MergeUpdate|MergeDelete|null $action
* @property bool $matchedBySource When false, represents `WHEN NOT MATCHED BY SOURCE`
*/
class MergeWhenMatched extends MergeWhenClause
{
protected MergeUpdate|MergeDelete|null $p_action;
protected bool $p_matchedBySource = true;

public function __construct(
?ScalarExpression $condition = null,
?Node $action = null,
bool $matchedBySource = true
) {
parent::__construct($condition, $action);

$this->p_matchedBySource = $matchedBySource;
}

public function setAction(?Node $action): void
{
if (null !== $action && !($action instanceof MergeDelete) && !($action instanceof MergeUpdate)) {
throw new InvalidArgumentException(\sprintf(
'Only UPDATE or DELETE action is possible for "WHEN MATCHED" clause, object(%s) given',
'Only UPDATE or DELETE action is possible for "WHEN MATCHED" / "WHEN NOT MATCHED BY SOURCE"'
. ' clause, object(%s) given',
$action::class
));
}
$this->setProperty($this->p_action, $action);
}

public function setMatchedBySource(bool $matchedBySource): void
{
$this->p_matchedBySource = $matchedBySource;
}

public function dispatch(TreeWalker $walker): mixed
{
return $walker->walkMergeWhenMatched($this);
Expand Down
4 changes: 2 additions & 2 deletions src/sad_spirit/pg_builder/nodes/merge/MergeWhenNotMatched.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
use sad_spirit\pg_builder\TreeWalker;

/**
* Represents a "WHEN NOT MATCHED" clause of MERGE statement
* Represents a `WHEN NOT MATCHED [BY TARGET]` clause of `MERGE` statement
*
* @property MergeInsert|null $action
*/
Expand All @@ -31,7 +31,7 @@ public function setAction(?Node $action): void
{
if (null !== $action && !($action instanceof MergeInsert)) {
throw new InvalidArgumentException(\sprintf(
'Only INSERT action is possible for "WHEN NOT MATCHED" clause, object(%s) given',
'Only INSERT action is possible for "WHEN NOT MATCHED [BY TARGET]" clause, object(%s) given',
$action::class
));
}
Expand Down
20 changes: 20 additions & 0 deletions tests/ParseMergeStatementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,14 @@ public function testParseAllClauses(): void
on bar.id is not distinct from "null"
when not matched and one = 2 then
insert (baz) overriding system value values ('quux')
when not matched by target and one > 2 then
insert (baz) values ('duh')
when matched and baz <> 'quux' then
update set baz = 'xyzzy'
when matched then
delete
when not matched by source then
update set baz = 'blah'
returning bar.*, merge_action()
QRY
);
Expand Down Expand Up @@ -115,6 +119,14 @@ public function testParseAllClauses(): void
)
);

$built->when[] = new MergeWhenNotMatched(
new OperatorExpression('>', new ColumnReference('one'), new NumericConstant('2')),
new MergeInsert(
new SetTargetList([new SetTargetElement('baz')]),
new MergeValues([new StringConstant('duh')])
)
);

$built->when[] = new MergeWhenMatched(
new OperatorExpression('<>', new ColumnReference('baz'), new StringConstant('quux')),
new MergeUpdate(new SetClauseList([
Expand All @@ -124,6 +136,14 @@ public function testParseAllClauses(): void

$built->when[] = new MergeWhenMatched(null, new MergeDelete());

$built->when[] = new MergeWhenMatched(
null,
new MergeUpdate(new SetClauseList([
new SingleSetClause(new SetTargetElement(new Identifier('baz')), new StringConstant('blah'))
])),
false
);

$built->returning[] = new TargetElement(new ColumnReference('bar', '*'));
$built->returning[] = new TargetElement(new MergeAction());

Expand Down
16 changes: 7 additions & 9 deletions tests/SqlBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,8 @@
*/
class SqlBuilderTest extends TestCase
{
/**
* @var Parser
*/
protected $parser;

/**
* @var SqlBuilderWalker
*/
protected $builder;
protected Parser $parser;
protected SqlBuilderWalker $builder;

protected function setUp(): void
{
Expand Down Expand Up @@ -130,12 +123,17 @@ public function testBuildMergeStatement(): void
on bar.id is not distinct from "null"
when not matched and one = 2 then
insert (baz) overriding system value values ('quux')
when not matched by target and one > 2 then
insert (baz) values ('duh')
when not matched and two = 1 then
insert default values
when matched and baz <> 'quux' then
update set baz = 'xyzzy'
when matched then
delete
when not matched by source then
update set baz = 'blah'
returning bar.*, merge_action()
QRY
);
}
Expand Down

0 comments on commit 6a3da91

Please sign in to comment.