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