packages/sql-catalog/src/Extension/Laravel/WriteCompiler.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Extension\Laravel;
6
7use SqlCatalog\Core\Evaluation\ArrayTerm;
8use SqlCatalog\Core\Evaluation\Domain;
9
10/**
11 * Compiles literal-shaped writes while preserving their value domains.
12 *
13 * @visibility root
14 */
15final class WriteCompiler
16{
17    /**
18     * Uses the configured framework SQL grammar.
19     */
20    public function __construct(private readonly Grammar $grammar)
21    {
22    }
23
24    /**
25     * @param list<Domain> $arguments
26     * @return array{Domain, Domain} SQL and ordered bindings.
27     */
28    public function compile(QueryState $state, string $method, array $arguments): array
29    {
30        if ($state->items('joins') !== [] || $state->items('orders') !== [] || $state->get('limit')->soleLiteral()?->value !== null) {
31            return $this->unknown('Laravel write modifiers are not modelled');
32        }
33        if ($method === 'delete' && count($arguments) <= 1) {
34            $state = $arguments === [] ? $state : (new Predicates($this->grammar))->basic($state, [$state->get('table')->concat(Domain::literal('.id')), $arguments[0]], 'and');
35
36            return isset($state->fields['problem']) ? [$state->get('problem'), Domain::unknown()]
37                : [Domain::literal('delete from ')->concat($this->grammar->wrap($state->get('table')))->concat($this->where($state)), QueryState::list($state->items('whereBindings'))];
38        }
39        if (in_array($method, ['increment', 'decrement'], true)) {
40            return $this->step($state, $method, $arguments);
41        }
42        $values = ($arguments[0] ?? Domain::unknown())->soleArray();
43        if ($values === null || !$values->complete || $values->entries === []) {
44            return $this->unknown('Laravel write columns are incomplete');
45        }
46        if ($method === 'update' && count($arguments) === 1) {
47            return $this->update($state, $values);
48        }
49        if (in_array($method, ['insert', 'insertorignore'], true) && count($arguments) === 1) {
50            return $this->insert($state, $values, $method === 'insertorignore');
51        }
52        if ($method === 'insertgetid' && count($arguments) <= 2) {
53            return $this->insertGetId($state, $values, $arguments[1] ?? Domain::literal(null));
54        }
55
56        return $this->unknown('Laravel write operation is not modelled: ' . $method);
57    }
58
59    /**
60     * @return array{Domain, Domain} Update assignments followed by WHERE bindings.
61     */
62    public function update(QueryState $state, ArrayTerm $values): array
63    {
64        if ($values->named() === [] || count($values->named()) !== count($values->entries)) {
65            return $this->unknown('Laravel update columns are unresolved');
66        }
67        $parts = [];
68        foreach ($values->named() as $column => $value) {
69            if ($value->soleArray() !== null) {
70                return $this->unknown('Laravel JSON update values are not modelled');
71            }
72            $parts[] = $this->grammar->wrap(Domain::literal($column))->concat(Domain::literal(' = '))->concat($this->grammar->parameter($value));
73        }
74        $sql = Domain::literal('update ')->concat($this->grammar->wrap($state->get('table')))
75            ->concat(Domain::literal(' set '))->concat($this->grammar->join($parts))->concat($this->where($state));
76
77        return [$sql, QueryState::list(array_merge($this->grammar->bindings(array_values($values->named())), $state->items('whereBindings')))];
78    }
79
80    /**
81     * @param list<Domain> $arguments
82     * @return array{Domain, Domain} An increment or decrement written as Laravel's raw arithmetic update.
83     */
84    public function step(QueryState $state, string $method, array $arguments): array
85    {
86        $column = ($arguments[0] ?? Domain::unknown())->soleLiteral()?->value;
87        $amount = $arguments[1] ?? Domain::literal(1);
88        $extra = isset($arguments[2]) ? $arguments[2]->soleArray() : QueryState::list([])->soleArray();
89        if (!is_string($column) || count($arguments) > 3 || $extra === null || !$extra->complete || $amount->soleArray() !== null || $amount->soleObject() !== null) {
90            return $this->unknown('Laravel ' . $method . ' overload is not modelled');
91        }
92        $literal = $amount->soleLiteral()?->value;
93        $step = $literal === null ? $amount : Domain::literal(is_numeric($literal) ? (string) $literal : '0');
94        $expression = $this->grammar->wrapName($column)->concat(Domain::literal($method === 'increment' ? ' + ' : ' - '))->concat($step);
95        $raw = new \SqlCatalog\Core\Evaluation\ObjectTerm(Grammar::EXPRESSION, state: (new QueryState(['sql' => $expression]))->array());
96        $values = new ArrayTerm(array_merge([new \SqlCatalog\Core\Evaluation\ArrayEntry(Domain::literal($column), Domain::of($raw))], $extra->entries));
97
98        return $this->update($state, $values);
99    }
100
101    /**
102     * @return array{Domain, Domain} Inserts one or more complete rows.
103     */
104    public function insert(QueryState $state, ArrayTerm $values, bool $ignore): array
105    {
106        $rows = $this->rows($values);
107        if ($rows === []) {
108            return $this->unknown('Laravel insert rows are inconsistent');
109        }
110        $columns = array_keys($rows[0]);
111        $groups = [];
112        $bindings = [];
113        foreach ($rows as $row) {
114            if (array_keys($row) !== $columns) {
115                return $this->unknown('Laravel insert rows have different columns');
116            }
117            $items = array_values($row);
118            $groups[] = Domain::literal('(')->concat($this->grammar->join(array_map($this->grammar->parameter(...), $items)))->concat(Domain::literal(')'));
119            $bindings = array_merge($bindings, $this->grammar->bindings($items));
120        }
121        $prefix = $this->grammar->dialect?->insertPrefix($ignore) ?? 'insert into ';
122        $sql = Domain::literal($prefix)->concat($this->grammar->wrap($state->get('table')))->concat(Domain::literal(' ('))
123            ->concat($this->grammar->join(array_map(fn (string $column): Domain => $this->grammar->wrap(Domain::literal($column)), $columns)))
124            ->concat(Domain::literal(') values '))->concat($this->grammar->join($groups));
125        $sql = $sql->concat(Domain::literal($this->grammar->dialect?->insertSuffix($ignore) ?? ''));
126
127        return [$sql, QueryState::list($bindings)];
128    }
129
130    /**
131     * @return array{Domain, Domain} A single-row insert whose generated key the grammar may have to return.
132     */
133    public function insertGetId(QueryState $state, ArrayTerm $values, Domain $sequence): array
134    {
135        if ($values->named() === [] || count($values->named()) !== count($values->entries)) {
136            return $this->unknown('Laravel insertGetId row is unresolved');
137        }
138        [$sql, $bindings] = $this->insert($state, $values, false);
139        $suffix = $this->grammar->dialect?->returningSuffix();
140        if ($suffix === null || $suffix === '') {
141            return [$sql, $bindings];
142        }
143        $name = $sequence->soleLiteral();
144
145        return [$sql->concat(Domain::literal($suffix))->concat($name?->value === null ? $this->grammar->wrap(Domain::literal('id')) : $this->grammar->wrap($sequence)), $bindings];
146    }
147
148    /**
149     * @return list<array<string, Domain>> Single rows retain order; bulk rows sort their keys.
150     */
151    public function rows(ArrayTerm $values): array
152    {
153        if ($values->named() !== []) {
154            return count($values->named()) === count($values->entries) ? [$values->named()] : [];
155        }
156        $rows = [];
157        foreach ($values->positional() as $value) {
158            $row = $value->soleArray();
159            if ($row === null || !$row->complete || $row->named() === [] || count($row->named()) !== count($row->entries)) {
160                return [];
161            }
162            $named = $row->named();
163            ksort($named);
164            $rows[] = $named;
165        }
166
167        return $rows;
168    }
169
170    /**
171     * The shared WHERE clause of an update or delete.
172     */
173    public function where(QueryState $state): Domain
174    {
175        return $state->items('where') === [] ? Domain::literal('') : Domain::literal(' where ')->concat($this->grammar->join($state->items('where'), ' '));
176    }
177
178    /**
179     * @return array{Domain, Domain} A visible gap, never a fabricated exact write.
180     */
181    public function unknown(string $reason): array
182    {
183        return [(new QueryState())->reject($reason)->get('problem'), Domain::unknown()];
184    }
185}
186