packages/ztd-query-postgres/src/Rewrite/Transformer/Merge/RowActions.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Platform\Postgres\Rewrite\Transformer\Merge;
6
7use ZtdQuery\Exception\UnsupportedSqlException;
8use ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertRowRenderer;
9use ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertSelectRenderer;
10use ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeActionKind;
11use ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeClause;
12use ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeMatchKind;
13use ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeStatement;
14use ZtdQuery\Platform\Postgres\Sql\PgSqlIdentifierQuoter;
15use ZtdQuery\Rewrite\ShadowIdentityAllocator;
16
17/**
18 * Row actions operations for PostgreSQL merge.
19 *
20 * @visibility root
21 */
22final class RowActions
23{
24    private readonly InsertSelectRenderer $insertSelectRenderer;
25    private readonly PgSqlIdentifierQuoter $quoter;
26    private readonly InsertRowRenderer $rowRenderer;
27
28    /**
29     * Supplies the dependencies used by this RowActions.
30     */
31    public function __construct(InsertSelectRenderer $insertSelectRenderer, PgSqlIdentifierQuoter $quoter, InsertRowRenderer $rowRenderer)
32    {
33        $this->insertSelectRenderer = $insertSelectRenderer;
34        $this->quoter = $quoter;
35        $this->rowRenderer = $rowRenderer;
36    }
37
38    /**
39     * @param list<string> $columns
40     * @param list<string> $effectiveConditions
41     */
42    public function unchangedRows(
43        PgSqlMergeStatement $statement,
44        array $columns,
45        array $effectiveConditions,
46    ): string {
47        $qualifier = $this->quoter->quote($statement->targetAlias);
48        $selects = [];
49        foreach ($columns as $column) {
50            $quoted = $this->quoter->quote($column);
51            $selects[] = $qualifier . '.' . $quoted . ' AS ' . $quoted;
52        }
53
54        $modifications = [];
55        foreach ($statement->clauses as $index => $clause) {
56            if ($clause->matchKind !== PgSqlMergeMatchKind::Matched
57                || !in_array($clause->actionKind, [PgSqlMergeActionKind::Update, PgSqlMergeActionKind::Delete], true)
58            ) {
59                continue;
60            }
61            $modifications[] = 'EXISTS (SELECT 1 FROM ' . $statement->sourceSql
62                . ' WHERE (' . $statement->joinConditionSql . ') AND (' . $effectiveConditions[$index] . '))';
63        }
64
65        $sql = 'SELECT ' . implode(', ', $selects)
66            . ' FROM ' . $statement->targetSql . ' AS ' . $qualifier;
67        if ($modifications !== []) {
68            $sql .= ' WHERE NOT (' . implode(' OR ', $modifications) . ')';
69        }
70
71        return $sql;
72    }
73
74    /**
75     * @param list<string> $columns
76     * @param array<string, string> $defaults
77     * @throws UnsupportedSqlException
78     */
79    public function updatedRows(
80        string $sql,
81        PgSqlMergeStatement $statement,
82        PgSqlMergeClause $clause,
83        array $columns,
84        array $defaults,
85        string $effectiveCondition,
86    ): string {
87        foreach (array_keys($clause->assignments) as $column) {
88            if (!in_array($column, $columns, true)) {
89                throw new UnsupportedSqlException($sql, 'MERGE UPDATE references an unknown target column');
90            }
91        }
92
93        $qualifier = $this->quoter->quote($statement->targetAlias);
94        $selects = [];
95        foreach ($columns as $column) {
96            $quoted = $this->quoter->quote($column);
97            $expression = $clause->assignments[$column] ?? null;
98            if ($expression === null) {
99                $expression = $qualifier . '.' . $quoted;
100            } elseif (strcasecmp($expression, 'DEFAULT') === 0) {
101                $expression = $defaults[$column] ?? 'NULL';
102            }
103            $selects[] = $expression . ' AS ' . $quoted;
104        }
105
106        return 'SELECT ' . implode(', ', $selects)
107            . ' FROM ' . $statement->targetSql . ' AS ' . $qualifier
108            . ' JOIN ' . $statement->sourceSql
109            . ' ON (' . $statement->joinConditionSql . ')'
110            . ' WHERE ' . $effectiveCondition;
111    }
112
113    /**
114     * @template T
115     * @param list<string> $columns
116     * @param array<string, string> $defaults
117     * @param array<string, \ZtdQuery\Schema\Key\IdentityGenerationStrategy> $identityStrategies
118     * @param array<int, array<string, T>> $existingRows
119     * @throws UnsupportedSqlException
120     */
121    public function insertedRows(
122        string $sql,
123        PgSqlMergeStatement $statement,
124        PgSqlMergeClause $clause,
125        array $columns,
126        array $defaults,
127        array $identityStrategies,
128        array $existingRows,
129        string $effectiveCondition,
130    ): string {
131        if ($clause->insertColumns !== []) {
132            $sourceColumns = $clause->insertColumns;
133        } elseif ($clause->insertValues === []) {
134            $sourceColumns = [];
135        } else {
136            $sourceColumns = $columns;
137        }
138        foreach ($sourceColumns as $column) {
139            if (!in_array($column, $columns, true)) {
140                throw new UnsupportedSqlException($sql, 'MERGE INSERT references an unknown target column');
141            }
142        }
143
144        if (count($sourceColumns) !== count($clause->insertValues)) {
145            throw new UnsupportedSqlException($sql, 'MERGE INSERT values count does not match column count');
146        }
147        $providedExpressions = $this->rowRenderer->providedExpressions($sourceColumns, $clause->insertValues);
148        $generatedStarts = (new ShadowIdentityAllocator())->allocateSelectStarts(
149            $statement->targetTable,
150            $identityStrategies,
151            array_keys($providedExpressions),
152            $existingRows,
153        );
154        foreach ($generatedStarts as $column => $start) {
155            $providedExpressions[$column] = $this->insertSelectRenderer->renderGeneratedIdentity($start);
156        }
157        $projected = $this->rowRenderer->render($columns, $providedExpressions, $defaults);
158
159        $selects = [];
160        foreach ($projected as $column => $expression) {
161            $selects[] = $expression . ' AS ' . $this->quoter->quote($column);
162        }
163        $targetAlias = $this->quoter->quote($statement->targetAlias);
164
165        return 'SELECT ' . implode(', ', $selects)
166            . ' FROM ' . $statement->sourceSql
167            . ' WHERE NOT EXISTS (SELECT 1 FROM ' . $statement->targetSql . ' AS ' . $targetAlias
168            . ' WHERE ' . $statement->joinConditionSql . ')'
169            . ' AND (' . $effectiveCondition . ')';
170    }
171    /**
172     * Projects UPDATE and INSERT branches using their ordered effective conditions.
173     * @template T
174     * @param list<string> $columns
175     * @param array<string, string> $defaults
176     * @param array<string, \ZtdQuery\Schema\Key\IdentityGenerationStrategy> $identityStrategies
177     * @param array<int, array<string, T>> $existingRows
178     * @param array<int, string> $effectiveConditions
179     * @return list<string>
180     */
181    public function modifiedRows(string $sql, PgSqlMergeStatement $statement, array $columns, array $defaults, array $identityStrategies, array $existingRows, array $effectiveConditions): array
182    {
183        $parts = [];
184        foreach ($statement->clauses as $index => $clause) {
185            $effective = $effectiveConditions[$index];
186            if ($clause->actionKind === PgSqlMergeActionKind::Update) {
187                $parts[] = $this->updatedRows($sql, $statement, $clause, $columns, $defaults, $effective);
188            }
189            if ($clause->actionKind === PgSqlMergeActionKind::Insert) {
190                $parts[] = $this->insertedRows(
191                    $sql,
192                    $statement,
193                    $clause,
194                    $columns,
195                    $defaults,
196                    $identityStrategies,
197                    $existingRows,
198                    $effective,
199                );
200            }
201        }
202
203        return $parts;
204    }
205}
206