1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Model\Scope;
6:
7: use Atk4\Core\ReadableCaptionTrait;
8: use Atk4\Data\Exception;
9: use Atk4\Data\Field;
10: use Atk4\Data\Model;
11: use Atk4\Data\Persistence;
12: use Atk4\Data\Persistence\Sql\Expression;
13: use Atk4\Data\Persistence\Sql\Expressionable;
14: use Atk4\Data\Persistence\Sql\Sqlite\Expression as SqliteExpression;
15:
16: class Condition extends AbstractScope
17: {
18: use ReadableCaptionTrait;
19:
20: /** @var string|Field|Expressionable */
21: public $key;
22:
23: /** @var string|null */
24: public $operator;
25:
26: /** @var mixed */
27: public $value;
28:
29: public const OPERATOR_EQUALS = '=';
30: public const OPERATOR_DOESNOT_EQUAL = '!=';
31: public const OPERATOR_GREATER = '>';
32: public const OPERATOR_GREATER_EQUAL = '>=';
33: public const OPERATOR_LESS = '<';
34: public const OPERATOR_LESS_EQUAL = '<=';
35: public const OPERATOR_LIKE = 'LIKE';
36: public const OPERATOR_NOT_LIKE = 'NOT LIKE';
37: public const OPERATOR_IN = 'IN';
38: public const OPERATOR_NOT_IN = 'NOT IN';
39: public const OPERATOR_REGEXP = 'REGEXP';
40: public const OPERATOR_NOT_REGEXP = 'NOT REGEXP';
41:
42: /** @var array<string, array<string, string>> */
43: protected static $operators = [
44: self::OPERATOR_EQUALS => [
45: 'negate' => self::OPERATOR_DOESNOT_EQUAL,
46: 'label' => 'is equal to',
47: ],
48: self::OPERATOR_DOESNOT_EQUAL => [
49: 'negate' => self::OPERATOR_EQUALS,
50: 'label' => 'is not equal to',
51: ],
52: self::OPERATOR_LESS => [
53: 'negate' => self::OPERATOR_GREATER_EQUAL,
54: 'label' => 'is smaller than',
55: ],
56: self::OPERATOR_GREATER => [
57: 'negate' => self::OPERATOR_LESS_EQUAL,
58: 'label' => 'is greater than',
59: ],
60: self::OPERATOR_GREATER_EQUAL => [
61: 'negate' => self::OPERATOR_LESS,
62: 'label' => 'is greater or equal to',
63: ],
64: self::OPERATOR_LESS_EQUAL => [
65: 'negate' => self::OPERATOR_GREATER,
66: 'label' => 'is smaller or equal to',
67: ],
68: self::OPERATOR_LIKE => [
69: 'negate' => self::OPERATOR_NOT_LIKE,
70: 'label' => 'is like',
71: ],
72: self::OPERATOR_NOT_LIKE => [
73: 'negate' => self::OPERATOR_LIKE,
74: 'label' => 'is not like',
75: ],
76: self::OPERATOR_IN => [
77: 'negate' => self::OPERATOR_NOT_IN,
78: 'label' => 'is one of',
79: ],
80: self::OPERATOR_NOT_IN => [
81: 'negate' => self::OPERATOR_IN,
82: 'label' => 'is not one of',
83: ],
84: self::OPERATOR_REGEXP => [
85: 'negate' => self::OPERATOR_NOT_REGEXP,
86: 'label' => 'is regular expression',
87: ],
88: self::OPERATOR_NOT_REGEXP => [
89: 'negate' => self::OPERATOR_REGEXP,
90: 'label' => 'is not regular expression',
91: ],
92: ];
93:
94: /**
95: * @param string|Expressionable $key
96: * @param ($value is null ? mixed : string) $operator
97: * @param ($operator is string ? mixed : never) $value
98: */
99: public function __construct($key, $operator = null, $value = null)
100: {
101: if ($key instanceof AbstractScope) {
102: throw new Exception('Only Scope can contain another conditions');
103: } elseif ($key instanceof Field) { // for BC
104: $key = $key->shortName;
105: } elseif (!is_string($key) && !$key instanceof Expressionable) { // @phpstan-ignore-line
106: throw new Exception('Field must be a string or an instance of Expressionable');
107: }
108:
109: if ('func_num_args'() === 2) {
110: $value = $operator;
111: $operator = self::OPERATOR_EQUALS;
112: }
113:
114: $this->key = $key;
115: $this->value = $value;
116:
117: if ($operator === null) {
118: // at least MSSQL database always requires an operator
119: if (!$key instanceof Expressionable) {
120: throw new Exception('Operator must be specified');
121: }
122: } else {
123: $this->operator = strtoupper($operator);
124:
125: if (!array_key_exists($this->operator, self::$operators)) {
126: throw (new Exception('Operator is not supported'))
127: ->addMoreInfo('operator', $operator);
128: }
129: }
130:
131: if (is_array($value)) {
132: foreach ($value as $v) {
133: if (is_array($v)) {
134: throw (new Exception('Multi-dimensional array as condition value is not supported'))
135: ->addMoreInfo('value', $value);
136: }
137: }
138:
139: if (!in_array($this->operator, [
140: self::OPERATOR_EQUALS,
141: self::OPERATOR_IN,
142: self::OPERATOR_DOESNOT_EQUAL,
143: self::OPERATOR_NOT_IN,
144: ], true)) {
145: throw (new Exception('Operator is not supported for array condition value'))
146: ->addMoreInfo('operator', $operator)
147: ->addMoreInfo('value', $value);
148: }
149: }
150: }
151:
152: #[\Override]
153: protected function onChangeModel(): void
154: {
155: $model = $this->getModel();
156: if ($model !== null) {
157: // if we have a definitive equal condition set the value as default value for field
158: // new records will automatically get this value assigned for the field
159: // TODO: fix when condition is part of OR scope
160: if ($this->operator === self::OPERATOR_EQUALS && !is_array($this->value)
161: && !$this->value instanceof Expressionable
162: && !$this->value instanceof Persistence\Array_\Action // needed to pass hintable tests
163: ) {
164: // key containing '/' means chained references and it is handled in toQueryArguments method
165: $field = $this->key;
166: if (is_string($field) && !str_contains($field, '/')) {
167: $field = $model->getField($field);
168: }
169:
170: // TODO Model/field should not be mutated, see:
171: // https://github.com/atk4/data/issues/662
172: // for now, do not set default at least for PK/ID
173: if ($field instanceof Field && $field->shortName !== $field->getOwner()->idField) {
174: $field->system = true;
175: $fakePersistence = new Persistence\Array_();
176: $valueCloned = $fakePersistence->typecastLoadField($field, $fakePersistence->typecastSaveField($field, $this->value));
177: $field->default = $valueCloned;
178: }
179: }
180: }
181: }
182:
183: /**
184: * @return array<0|1|2, mixed>
185: */
186: public function toQueryArguments(): array
187: {
188: if ($this->isEmpty()) {
189: return [];
190: }
191:
192: $field = $this->key;
193: $operator = $this->operator;
194: $value = $this->value;
195:
196: $model = $this->getModel();
197: if ($model !== null) {
198: if (is_string($field)) {
199: // shorthand for adding conditions on references
200: // use chained reference names separated by "/"
201: if (str_contains($field, '/')) {
202: $references = explode('/', $field);
203: $field = array_pop($references);
204:
205: $refModels = [];
206: $refModel = $model;
207: foreach ($references as $link) {
208: $refModel = $refModel->refLink($link);
209: $refModels[] = $refModel;
210: }
211: unset($refModel);
212:
213: foreach (array_reverse($refModels) as $refModel) {
214: if ($field === '#') {
215: if (is_string($value) && $value === (string) (int) $value) {
216: $value = (int) $value;
217: }
218:
219: if ($value === 0) {
220: $field = $refModel->action('exists');
221: $value = false;
222: } elseif ($value === 1 && $operator === self::OPERATOR_GREATER_EQUAL) {
223: $field = $refModel->action('exists');
224: $operator = self::OPERATOR_EQUALS;
225: $value = true;
226: } else {
227: $field = $refModel->action('count');
228: }
229: } else {
230: $refModel->addCondition($field, $operator, $value);
231: $field = $refModel->action('exists');
232: $operator = self::OPERATOR_EQUALS;
233: $value = true;
234: }
235: }
236: } else {
237: $field = $model->getField($field);
238: }
239: }
240:
241: // handle the query arguments using field
242: if ($field instanceof Field) {
243: [$field, $operator, $value] = $field->getQueryArguments($operator, $value);
244: }
245:
246: // only expression contained in $field
247: if (!$operator) {
248: return [$field];
249: }
250:
251: // skip explicitly using OPERATOR_EQUALS as in some cases it is transformed to OPERATOR_IN
252: // for instance in dsql so let exact operator be handled by Persistence
253: if ($operator === self::OPERATOR_EQUALS) {
254: return [$field, $value];
255: }
256: }
257:
258: return [$field, $operator, $value];
259: }
260:
261: #[\Override]
262: public function isEmpty(): bool
263: {
264: return array_filter([$this->key, $this->operator, $this->value]) ? false : true;
265: }
266:
267: #[\Override]
268: public function clear(): self
269: {
270: $this->key = null; // @phpstan-ignore-line
271: $this->operator = null;
272: $this->value = null;
273:
274: return $this;
275: }
276:
277: #[\Override]
278: public function negate(): self
279: {
280: if (isset(self::$operators[$this->operator]['negate'])) {
281: $this->operator = self::$operators[$this->operator]['negate'];
282: } else {
283: throw (new Exception('Negation of condition is not supported for this operator'))
284: ->addMoreInfo('operator', $this->operator ?? 'no operator');
285: }
286:
287: return $this;
288: }
289:
290: #[\Override]
291: public function toWords(Model $model = null): string
292: {
293: if ($model === null) {
294: $model = $this->getModel();
295: }
296:
297: if ($model === null) {
298: throw new Exception('Condition must be associated with Model to convert to words');
299: }
300:
301: $key = $this->keyToWords($model);
302: $operator = $this->operatorToWords();
303: $value = $this->valueToWords($model, $this->value);
304:
305: return trim($key . ' ' . $operator . ' ' . $value);
306: }
307:
308: protected function keyToWords(Model $model): string
309: {
310: $words = [];
311:
312: $field = $this->key;
313: if (is_string($field)) {
314: if (str_contains($field, '/')) {
315: $references = explode('/', $field);
316:
317: $words[] = $model->getModelCaption();
318:
319: $field = array_pop($references);
320:
321: foreach ($references as $link) {
322: $words[] = 'that has reference ' . $this->readableCaption($link);
323:
324: $model = $model->refLink($link);
325: }
326:
327: $words[] = 'where';
328:
329: if ($field === '#') {
330: $words[] = $this->operator ? 'number of records' : 'any referenced record exists';
331: }
332: }
333:
334: if ($model->hasField($field)) {
335: $field = $model->getField($field);
336: }
337: }
338:
339: if ($field instanceof Field) {
340: $words[] = $field->getCaption();
341: } elseif ($field instanceof Expressionable) {
342: $words[] = $this->valueToWords($model, $field);
343: }
344:
345: return implode(' ', array_filter($words));
346: }
347:
348: protected function operatorToWords(): string
349: {
350: return $this->operator ? self::$operators[$this->operator]['label'] : '';
351: }
352:
353: /**
354: * @param mixed $value
355: */
356: protected function valueToWords(Model $model, $value): string
357: {
358: if ($value === null) {
359: return $this->operator ? 'empty' : '';
360: }
361:
362: if (is_array($value)) {
363: $res = [];
364: foreach ($value as $v) {
365: $res[] = $this->valueToWords($model, $v);
366: }
367:
368: return implode(' or ', $res);
369: }
370:
371: if (is_object($value)) {
372: if ($value instanceof Field) {
373: return $value->getOwner()->getModelCaption() . ' ' . $value->getCaption();
374: }
375:
376: if ($value instanceof Expressionable) {
377: return 'expression \'' . $value->getDsqlExpression(new SqliteExpression())->getDebugQuery() . '\'';
378: }
379:
380: return 'object ' . print_r($value, true);
381: }
382:
383: // handling of scope on references
384: $field = $this->key;
385: if (is_string($field)) {
386: if (str_contains($field, '/')) {
387: $references = explode('/', $field);
388:
389: $field = array_pop($references);
390:
391: foreach ($references as $link) {
392: $model = $model->refLink($link);
393: }
394: }
395:
396: if ($model->hasField($field)) {
397: $field = $model->getField($field);
398: }
399: }
400:
401: // use the referenced model title if such exists
402: $title = null;
403: if ($field instanceof Field && $field->hasReference()) {
404: // make sure we set the value in the Model
405: $entity = $model->isEntity() ? clone $model : $model->createEntity();
406: $entity->set($field->shortName, $value);
407:
408: // then take the title
409: $title = $entity->ref($field->getReference()->link)->getTitle();
410: if ($title === $value) {
411: $title = null;
412: }
413: }
414:
415: if (is_bool($value)) {
416: $valueStr = $value ? 'true' : 'false';
417: } elseif (is_int($value)) {
418: $valueStr = (string) $value;
419: } elseif (is_float($value)) {
420: $valueStr = Expression::castFloatToString($value);
421: } else {
422: $valueStr = '\'' . (string) $value . '\'';
423: }
424:
425: return $valueStr . ($title !== null ? ' (\'' . $title . '\')' : '');
426: }
427: }
428: