1
|
|
|
<?php |
2
|
|
|
declare(strict_types=1); |
3
|
|
|
namespace Ivory\Query; |
4
|
|
|
|
5
|
|
|
use Ivory\Exception\InvalidStateException; |
6
|
|
|
use Ivory\Exception\NoDataException; |
7
|
|
|
use Ivory\Ivory; |
8
|
|
|
use Ivory\Lang\SqlPattern\SqlPattern; |
9
|
|
|
use Ivory\Lang\SqlPattern\SqlPatternPlaceholder; |
10
|
|
|
use Ivory\Type\ITypeDictionary; |
11
|
|
|
use Ivory\Type\IValueSerializer; |
12
|
|
|
use Ivory\Utils\StringUtils; |
13
|
|
|
|
14
|
|
|
trait SqlPatternDefinitionMacros |
15
|
|
|
{ |
16
|
|
|
/** @var SqlPattern */ |
17
|
|
|
private $sqlPattern; |
18
|
|
|
/** @var array map: parameter name or position => supplied value */ |
19
|
|
|
private $params; |
20
|
|
|
/** @var bool[] map: name of parameter which has not been set any value yet => <tt>true</tt> value */ |
21
|
|
|
private $unsatisfiedParams; |
22
|
|
|
|
23
|
|
|
|
24
|
|
|
/** |
25
|
|
|
* Creates an SQL definition from an SQL string. |
26
|
|
|
* |
27
|
|
|
* No parameter substitution is performed on the string - it is used as is. |
28
|
|
|
* |
29
|
|
|
* @param string $sql SQL string |
30
|
|
|
* @return static |
31
|
|
|
*/ |
32
|
|
|
public static function fromSql(string $sql): self |
33
|
|
|
{ |
34
|
|
|
$sqlPattern = new SqlPattern($sql, [], []); |
35
|
|
|
return new static($sqlPattern, []); |
36
|
|
|
} |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* Creates a new definition from an SQL pattern. |
40
|
|
|
* |
41
|
|
|
* Values for all positional parameters required by the pattern must be given. |
42
|
|
|
* |
43
|
|
|
* Example: |
44
|
|
|
* <code> |
45
|
|
|
* // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'" |
46
|
|
|
* $relDef = SqlRelationDefinition::fromPattern( |
47
|
|
|
* 'SELECT * FROM person WHERE role = %i AND email = %s', |
48
|
|
|
* 4, '[email protected]' |
49
|
|
|
* ); |
50
|
|
|
* |
51
|
|
|
* // command defined by "DELETE FROM mytable WHERE id < 100" |
52
|
|
|
* $cmd = SqlCommand::fromPattern( |
53
|
|
|
* 'DELETE FROM %ident WHERE id < %i', |
54
|
|
|
* 'mytable', 100 |
55
|
|
|
* ); |
56
|
|
|
* </code> |
57
|
|
|
* |
58
|
|
|
* Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by |
59
|
|
|
* {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results |
60
|
|
|
* and reuse them for the same pattern next time. |
61
|
|
|
* |
62
|
|
|
* @param string|SqlPattern $sqlPattern |
63
|
|
|
* @param mixed ...$positionalParameters |
64
|
|
|
* @return static |
65
|
|
|
* @throws \InvalidArgumentException when the number of provided positional parameters differs from the number of |
66
|
|
|
* positional parameters required by the pattern |
67
|
|
|
*/ |
68
|
|
|
public static function fromPattern($sqlPattern, ...$positionalParameters): self |
69
|
|
|
{ |
70
|
|
|
if (!$sqlPattern instanceof SqlPattern) { |
71
|
|
|
$parser = Ivory::getSqlPatternParser(); |
72
|
|
|
$sqlPattern = $parser->parse($sqlPattern); |
73
|
|
|
} |
74
|
|
|
|
75
|
|
|
if (count($sqlPattern->getPositionalPlaceholders()) != count($positionalParameters)) { |
76
|
|
|
throw new \InvalidArgumentException(sprintf( |
77
|
|
|
'The SQL pattern requires %d positional parameters, %d given.', |
78
|
|
|
count($sqlPattern->getPositionalPlaceholders()), |
79
|
|
|
count($positionalParameters) |
80
|
|
|
)); |
81
|
|
|
} |
82
|
|
|
|
83
|
|
|
return new static($sqlPattern, $positionalParameters); |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
/** |
87
|
|
|
* Creates an SQL definition from one or more fragments, each with its own positional parameters. |
88
|
|
|
* |
89
|
|
|
* Each fragment must be immediately followed by values for all positional parameters it requires. Then, another |
90
|
|
|
* fragment may follow. As the very last argument, a map of values for named parameters may optionally be given (or |
91
|
|
|
* {@link setParams()} may be used to set them later). |
92
|
|
|
* |
93
|
|
|
* The fragments get concatenated to form the resulting SQL pattern. A single space is added between each two |
94
|
|
|
* fragments the former of which ends with a non-whitespace character and the latter of which starts with a |
95
|
|
|
* non-whitespace character. |
96
|
|
|
* |
97
|
|
|
* Named parameters are shared among fragments. In other words, if two fragments use the same named parameter, |
98
|
|
|
* specifying the parameter by {@link setParam()} will substitute the same value to both fragments. |
99
|
|
|
* |
100
|
|
|
* Example: |
101
|
|
|
* <code> |
102
|
|
|
* // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'" |
103
|
|
|
* $relDef = SqlRelationDefinition::fromFragments( |
104
|
|
|
* 'SELECT * FROM person WHERE role = %i', 4, 'AND email = %s', '[email protected]' |
105
|
|
|
* ); |
106
|
|
|
* |
107
|
|
|
* // command defined by "DELETE FROM mytable WHERE id < 100" |
108
|
|
|
* $cmd = SqlCommand::fromFragments( |
109
|
|
|
* 'DELETE FROM %ident', 'mytable', |
110
|
|
|
* 'WHERE id < %i', 100 |
111
|
|
|
* ); |
112
|
|
|
* </code> |
113
|
|
|
* |
114
|
|
|
* Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by |
115
|
|
|
* {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results |
116
|
|
|
* and reuse them for the same pattern next time. |
117
|
|
|
* |
118
|
|
|
* _Ivory design note: The single space added between each two fragments aspires to be more practical than a mere |
119
|
|
|
* concatenation, which would require the user to specify spaces where the next fragment immediately continued with |
120
|
|
|
* the query. After all, the method has ambitions to at least partly understand the user wants to compose an SQL |
121
|
|
|
* query from several parts, thus, it is legitimate the query is modified appropriately._ |
122
|
|
|
* |
123
|
|
|
* @param string|SqlPattern $fragment |
124
|
|
|
* @param mixed ...$fragmentsAndParamValues |
125
|
|
|
* further fragments (each of which is either a <tt>string</tt> or an |
126
|
|
|
* {@link SqlPattern} object) and values of their parameters; |
127
|
|
|
* the very last argument may be a map of values for named parameters to set |
128
|
|
|
* immediately |
129
|
|
|
* @return static |
130
|
|
|
* @throws \InvalidArgumentException when any fragment is not followed by the exact number of parameter values it |
131
|
|
|
* requires |
132
|
|
|
*/ |
133
|
|
|
public static function fromFragments($fragment, ...$fragmentsAndParamValues): self |
134
|
|
|
{ |
135
|
|
|
// OPT: consider caching the overall pattern, saving the most of the hard work |
136
|
|
|
|
137
|
|
|
$overallSqlTorso = ''; |
138
|
|
|
$overallPosPlaceholders = []; |
139
|
|
|
$overallNamedPlaceholderMap = []; |
140
|
|
|
$overallPosParams = []; |
141
|
|
|
|
142
|
|
|
$namedParamValues = []; |
143
|
|
|
|
144
|
|
|
$curFragment = $fragment; |
145
|
|
|
$curFragmentNum = 1; |
146
|
|
|
$argsProcessed = 0; |
147
|
|
|
$overallEndsWithPlaceholder = false; |
148
|
|
|
do { |
149
|
|
|
// process the fragment |
150
|
|
|
if (!$curFragment instanceof SqlPattern) { |
151
|
|
|
if (is_string($curFragment)) { |
152
|
|
|
$parser = Ivory::getSqlPatternParser(); |
153
|
|
|
$curFragment = $parser->parse($curFragment); |
154
|
|
|
} elseif ( |
155
|
|
|
is_iterable($curFragment) && |
156
|
|
|
$argsProcessed > 0 && |
157
|
|
|
!array_key_exists($argsProcessed, $fragmentsAndParamValues) |
158
|
|
|
) { |
159
|
|
|
$namedParamValues = $curFragment; |
160
|
|
|
break; |
161
|
|
|
} else { |
162
|
|
|
$ord = StringUtils::englishOrd($curFragmentNum); |
163
|
|
|
throw new \InvalidArgumentException( |
164
|
|
|
"Invalid type of $ord fragment. Isn't it a misplaced parameter value?" |
165
|
|
|
); |
166
|
|
|
} |
167
|
|
|
} |
168
|
|
|
|
169
|
|
|
// add to the overall pattern |
170
|
|
|
$curSqlTorso = $curFragment->getSqlTorso(); |
171
|
|
|
$curPosParams = $curFragment->getPositionalPlaceholders(); |
172
|
|
|
if (self::needsSpaceAsGlue($curFragment, $overallSqlTorso, $overallEndsWithPlaceholder)) { |
173
|
|
|
$overallSqlTorso .= ' '; |
174
|
|
|
} |
175
|
|
|
$sqlTorsoOffset = strlen($overallSqlTorso); |
176
|
|
|
$sqlTorsoLen = strlen($curSqlTorso); |
177
|
|
|
$overallSqlTorso .= $curSqlTorso; |
178
|
|
|
$overallEndsWithPlaceholder = false; |
179
|
|
|
foreach ($curPosParams as $plcHdr) { |
180
|
|
|
$overallPlcHdr = new SqlPatternPlaceholder( |
181
|
|
|
$sqlTorsoOffset + $plcHdr->getOffset(), |
182
|
|
|
count($overallPosPlaceholders), |
183
|
|
|
$plcHdr->getTypeName(), |
184
|
|
|
$plcHdr->isTypeNameQuoted(), |
185
|
|
|
$plcHdr->getSchemaName(), |
186
|
|
|
$plcHdr->isSchemaNameQuoted(), |
187
|
|
|
$plcHdr->isLooseTypeMode() |
188
|
|
|
); |
189
|
|
|
$overallPosPlaceholders[] = $overallPlcHdr; |
190
|
|
|
$overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen); |
191
|
|
|
} |
192
|
|
|
foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) { |
193
|
|
|
if (!isset($overallNamedPlaceholderMap[$name])) { |
194
|
|
|
$overallNamedPlaceholderMap[$name] = []; |
195
|
|
|
} |
196
|
|
|
foreach ($occurrences as $plcHdr) { |
197
|
|
|
assert($plcHdr instanceof SqlPatternPlaceholder); |
198
|
|
|
$overallPlcHdr = new SqlPatternPlaceholder( |
199
|
|
|
$sqlTorsoOffset + $plcHdr->getOffset(), |
200
|
|
|
$name, |
201
|
|
|
$plcHdr->getTypeName(), |
202
|
|
|
$plcHdr->isTypeNameQuoted(), |
203
|
|
|
$plcHdr->getSchemaName(), |
204
|
|
|
$plcHdr->isSchemaNameQuoted(), |
205
|
|
|
$plcHdr->isLooseTypeMode() |
206
|
|
|
); |
207
|
|
|
$overallNamedPlaceholderMap[$name][] = $overallPlcHdr; |
208
|
|
|
$overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen); |
209
|
|
|
} |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
// values of parameters |
213
|
|
|
$plcHdrCnt = count($curPosParams); |
214
|
|
|
$posParams = array_slice($fragmentsAndParamValues, $argsProcessed, $plcHdrCnt); |
215
|
|
|
if (count($posParams) == $plcHdrCnt) { |
216
|
|
|
$overallPosParams = array_merge($overallPosParams, $posParams); |
217
|
|
|
} else { |
218
|
|
|
$ord = StringUtils::englishOrd($curFragmentNum); |
219
|
|
|
throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment"); |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
$curFragmentNum++; |
223
|
|
|
$argsProcessed += count($posParams); |
224
|
|
|
|
225
|
|
|
$curFragment =& $fragmentsAndParamValues[$argsProcessed]; |
226
|
|
|
$argsProcessed++; |
227
|
|
|
} while (isset($curFragment)); |
228
|
|
|
|
229
|
|
|
$overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap); |
230
|
|
|
|
231
|
|
|
$def = new static($overallPattern, $overallPosParams); |
232
|
|
|
$def->setParams($namedParamValues); |
233
|
|
|
return $def; |
234
|
|
|
} |
235
|
|
|
|
236
|
|
|
private static function needsSpaceAsGlue( |
237
|
|
|
SqlPattern $curFragment, |
238
|
|
|
string $overallSqlTorso, |
239
|
|
|
bool $overallEndsWithPlaceholder |
240
|
|
|
): bool { |
241
|
|
|
/** |
242
|
|
|
* The glue is needed if the overall part ends with a non-space character or placeholder and, at the same time, |
243
|
|
|
* the current fragment starts with a non-space character or placeholder. |
244
|
|
|
*/ |
245
|
|
|
|
246
|
|
|
if (!$overallEndsWithPlaceholder && !preg_match('~[^ \t\r\n]$~uD', $overallSqlTorso)) { |
247
|
|
|
return false; |
248
|
|
|
} |
249
|
|
|
|
250
|
|
|
$curPosParams = $curFragment->getPositionalPlaceholders(); |
251
|
|
|
if ($curPosParams && $curPosParams[0]->getOffset() == 0) { |
|
|
|
|
252
|
|
|
return true; |
253
|
|
|
} |
254
|
|
|
|
255
|
|
|
$curNamedParams = $curFragment->getNamedPlaceholderMap(); |
256
|
|
|
// OPT: Require SqlPattern::$namedPlaceholderMap to be sorted by offset of the first occurrence of the name. |
257
|
|
|
// Then, take just the first item instead of iterating over all names. |
258
|
|
|
foreach ($curNamedParams as $occurrences) { |
259
|
|
|
assert($occurrences[0] instanceof SqlPatternPlaceholder); |
260
|
|
|
if ($occurrences[0]->getOffset() == 0) { // occurrences are sorted, so checking only the first is sufficient |
261
|
|
|
return true; |
262
|
|
|
} |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
$curSqlTorso = $curFragment->getSqlTorso(); |
266
|
|
|
return (bool)preg_match('~^[^ \t\r\n]~u', $curSqlTorso); |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
public static function getReferencedSerializer( |
270
|
|
|
SqlPatternPlaceholder $placeholder, |
271
|
|
|
ITypeDictionary $typeDictionary |
272
|
|
|
): IValueSerializer { |
273
|
|
|
$typeName = $placeholder->getTypeName(); |
274
|
|
|
if (!$placeholder->isTypeNameQuoted()) { |
275
|
|
|
$typeName = mb_strtolower($typeName); |
|
|
|
|
276
|
|
|
// OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached |
277
|
|
|
} |
278
|
|
|
|
279
|
|
|
$schemaName = $placeholder->getSchemaName(); |
280
|
|
|
if ($schemaName !== null) { |
281
|
|
|
if (!$placeholder->isSchemaNameQuoted()) { |
282
|
|
|
$schemaName = mb_strtolower($schemaName); |
283
|
|
|
// OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached |
284
|
|
|
} |
285
|
|
|
} elseif ($placeholder->isTypeNameQuoted()) { |
286
|
|
|
$schemaName = false; |
287
|
|
|
} |
288
|
|
|
|
289
|
|
|
$serializer = null; |
290
|
|
|
if ($schemaName === null) { |
291
|
|
|
$serializer = $typeDictionary->getValueSerializer($typeName); |
|
|
|
|
292
|
|
|
} |
293
|
|
|
if ($serializer === null) { |
294
|
|
|
$serializer = $typeDictionary->requireTypeByName($typeName, $schemaName); |
|
|
|
|
295
|
|
|
} |
296
|
|
|
|
297
|
|
|
return $serializer; |
298
|
|
|
} |
299
|
|
|
|
300
|
|
|
|
301
|
|
|
final private function __construct(SqlPattern $sqlPattern, array $positionalParameters) |
302
|
|
|
{ |
303
|
|
|
$this->sqlPattern = $sqlPattern; |
304
|
|
|
$this->params = $positionalParameters; |
305
|
|
|
$this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true); |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
public function setParam($nameOrPosition, $value) |
309
|
|
|
{ |
310
|
|
|
if (isset($this->unsatisfiedParams[$nameOrPosition])) { |
311
|
|
|
unset($this->unsatisfiedParams[$nameOrPosition]); |
312
|
|
|
} elseif (!array_key_exists($nameOrPosition, $this->params)) { |
313
|
|
|
throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'"); |
314
|
|
|
} |
315
|
|
|
|
316
|
|
|
$this->params[$nameOrPosition] = $value; |
317
|
|
|
return $this; |
318
|
|
|
} |
319
|
|
|
|
320
|
|
|
public function setParams(iterable $paramMap) |
321
|
|
|
{ |
322
|
|
|
foreach ($paramMap as $nameOrPosition => $value) { |
323
|
|
|
$this->setParam($nameOrPosition, $value); |
324
|
|
|
} |
325
|
|
|
return $this; |
326
|
|
|
} |
327
|
|
|
|
328
|
|
|
public function getSqlPattern(): SqlPattern |
329
|
|
|
{ |
330
|
|
|
return $this->sqlPattern; |
331
|
|
|
} |
332
|
|
|
|
333
|
|
|
public function getParams(): array |
334
|
|
|
{ |
335
|
|
|
return $this->params; |
336
|
|
|
} |
337
|
|
|
|
338
|
|
|
public function toSql(ITypeDictionary $typeDictionary, array $namedParameterValues = []): string |
339
|
|
|
{ |
340
|
|
|
$unsatisfiedParams = array_diff_key($this->unsatisfiedParams, $namedParameterValues); |
341
|
|
|
|
342
|
|
|
if ($unsatisfiedParams) { |
|
|
|
|
343
|
|
|
$names = array_keys($unsatisfiedParams); |
344
|
|
|
if (count($names) == 1) { |
345
|
|
|
$msg = sprintf('Value for parameter "%s" has not been set.', $names[0]); |
346
|
|
|
} else { |
347
|
|
|
$msg = sprintf( |
348
|
|
|
'Values for parameters %s and "%s" have not been set.', |
349
|
|
|
implode(', ', array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))), |
350
|
|
|
$names[count($names) - 1] |
351
|
|
|
); |
352
|
|
|
} |
353
|
|
|
throw new InvalidStateException($msg); |
354
|
|
|
} |
355
|
|
|
|
356
|
|
|
$gen = $this->sqlPattern->generateSql(); |
357
|
|
|
while ($gen->valid()) { |
358
|
|
|
$placeholder = $gen->current(); |
359
|
|
|
assert($placeholder instanceof SqlPatternPlaceholder); |
360
|
|
|
$nameOrPos = $placeholder->getNameOrPosition(); |
361
|
|
|
|
362
|
|
|
if (array_key_exists($nameOrPos, $namedParameterValues)) { |
363
|
|
|
$value = $namedParameterValues[$nameOrPos]; |
364
|
|
|
} else { |
365
|
|
|
assert( |
366
|
|
|
array_key_exists($placeholder->getNameOrPosition(), $this->params), |
367
|
|
|
new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.") |
368
|
|
|
); |
369
|
|
|
$value = $this->params[$nameOrPos]; |
370
|
|
|
} |
371
|
|
|
|
372
|
|
|
if ($placeholder->getTypeName() !== null) { |
373
|
|
|
$serializer = static::getReferencedSerializer($placeholder, $typeDictionary); |
374
|
|
|
} else { |
375
|
|
|
$serializer = $typeDictionary->requireTypeByValue($value); |
376
|
|
|
} |
377
|
|
|
$serializedValue = $serializer->serializeValue($value, !$placeholder->isLooseTypeMode()); |
378
|
|
|
|
379
|
|
|
$gen->send($serializedValue); |
380
|
|
|
} |
381
|
|
|
|
382
|
|
|
$sql = $gen->getReturn(); |
383
|
|
|
assert(is_string($sql)); |
384
|
|
|
return $sql; |
385
|
|
|
} |
386
|
|
|
} |
387
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.