Skip to content

Commit af15066

Browse files
authored
Merge pull request #770 from utopia-php/feat-relationship-operators
Add operator support for relationship arrays
2 parents 5da71b6 + 737eee6 commit af15066

File tree

5 files changed

+623
-7
lines changed

5 files changed

+623
-7
lines changed

src/Database/Database.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5205,6 +5205,11 @@ public function updateDocument(string $collection, string $id, Document $documen
52055205
break;
52065206
}
52075207

5208+
if (Operator::isOperator($value)) {
5209+
$shouldUpdate = true;
5210+
break;
5211+
}
5212+
52085213
if (!\is_array($value) || !\array_is_list($value)) {
52095214
throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.');
52105215
}
@@ -5610,6 +5615,24 @@ private function updateDocumentRelationships(Document $collection, Document $old
56105615
$twoWayKey = (string)$relationship['options']['twoWayKey'];
56115616
$side = (string)$relationship['options']['side'];
56125617

5618+
if (Operator::isOperator($value)) {
5619+
$operator = $value;
5620+
if ($operator->isArrayOperation()) {
5621+
$existingIds = [];
5622+
if (\is_array($oldValue)) {
5623+
$existingIds = \array_map(function ($item) {
5624+
if ($item instanceof Document) {
5625+
return $item->getId();
5626+
}
5627+
return $item;
5628+
}, $oldValue);
5629+
}
5630+
5631+
$value = $this->applyRelationshipOperator($operator, $existingIds);
5632+
$document->setAttribute($key, $value);
5633+
}
5634+
}
5635+
56135636
if ($oldValue == $value) {
56145637
if (
56155638
($relationType === Database::RELATION_ONE_TO_ONE
@@ -5969,6 +5992,63 @@ private function getJunctionCollection(Document $collection, Document $relatedCo
59695992
: '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence();
59705993
}
59715994

5995+
/**
5996+
* Apply an operator to a relationship array of IDs
5997+
*
5998+
* @param Operator $operator
5999+
* @param array<string> $existingIds
6000+
* @return array<string|Document>
6001+
*/
6002+
private function applyRelationshipOperator(Operator $operator, array $existingIds): array
6003+
{
6004+
$method = $operator->getMethod();
6005+
$values = $operator->getValues();
6006+
6007+
// Extract IDs from operator values (could be strings or Documents)
6008+
$valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values));
6009+
6010+
switch ($method) {
6011+
case Operator::TYPE_ARRAY_APPEND:
6012+
return \array_values(\array_merge($existingIds, $valueIds));
6013+
6014+
case Operator::TYPE_ARRAY_PREPEND:
6015+
return \array_values(\array_merge($valueIds, $existingIds));
6016+
6017+
case Operator::TYPE_ARRAY_INSERT:
6018+
$index = $values[0] ?? 0;
6019+
$item = $values[1] ?? null;
6020+
$itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null);
6021+
if ($itemId !== null) {
6022+
\array_splice($existingIds, $index, 0, [$itemId]);
6023+
}
6024+
return \array_values($existingIds);
6025+
6026+
case Operator::TYPE_ARRAY_REMOVE:
6027+
$toRemove = $values[0] ?? null;
6028+
if (\is_array($toRemove)) {
6029+
$toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove));
6030+
return \array_values(\array_diff($existingIds, $toRemoveIds));
6031+
}
6032+
$toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null);
6033+
if ($toRemoveId !== null) {
6034+
return \array_values(\array_diff($existingIds, [$toRemoveId]));
6035+
}
6036+
return $existingIds;
6037+
6038+
case Operator::TYPE_ARRAY_UNIQUE:
6039+
return \array_values(\array_unique($existingIds));
6040+
6041+
case Operator::TYPE_ARRAY_INTERSECT:
6042+
return \array_values(\array_intersect($existingIds, $valueIds));
6043+
6044+
case Operator::TYPE_ARRAY_DIFF:
6045+
return \array_values(\array_diff($existingIds, $valueIds));
6046+
6047+
default:
6048+
return $existingIds;
6049+
}
6050+
}
6051+
59726052
/**
59736053
* Create or update a document.
59746054
*

src/Database/Validator/Operator.php

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,50 @@ public function __construct(Document $collection, ?Document $currentDocument = n
3636
}
3737
}
3838

39+
/**
40+
* Check if a value is a valid relationship reference (string ID or Document)
41+
*
42+
* @param mixed $item
43+
* @return bool
44+
*/
45+
private function isValidRelationshipValue(mixed $item): bool
46+
{
47+
return \is_string($item) || $item instanceof Document;
48+
}
49+
50+
/**
51+
* Check if a relationship attribute represents a "many" side (returns array of documents)
52+
*
53+
* @param Document|array<string, mixed> $attribute
54+
* @return bool
55+
*/
56+
private function isRelationshipArray(Document|array $attribute): bool
57+
{
58+
$options = $attribute instanceof Document
59+
? $attribute->getAttribute('options', [])
60+
: ($attribute['options'] ?? []);
61+
62+
$relationType = $options['relationType'] ?? '';
63+
$side = $options['side'] ?? '';
64+
65+
// Many-to-many is always an array on both sides
66+
if ($relationType === Database::RELATION_MANY_TO_MANY) {
67+
return true;
68+
}
69+
70+
// One-to-many: array on parent side, single on child side
71+
if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) {
72+
return true;
73+
}
74+
75+
// Many-to-one: array on child side, single on parent side
76+
if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) {
77+
return true;
78+
}
79+
80+
return false;
81+
}
82+
3983
/**
4084
* Get Description
4185
*
@@ -165,7 +209,19 @@ private function validateOperatorForAttribute(
165209
break;
166210
case DatabaseOperator::TYPE_ARRAY_APPEND:
167211
case DatabaseOperator::TYPE_ARRAY_PREPEND:
168-
if (!$isArray) {
212+
// For relationships, check if it's a "many" side
213+
if ($type === Database::VAR_RELATIONSHIP) {
214+
if (!$this->isRelationshipArray($attribute)) {
215+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
216+
return false;
217+
}
218+
foreach ($values as $item) {
219+
if (!$this->isValidRelationshipValue($item)) {
220+
$this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects";
221+
return false;
222+
}
223+
}
224+
} elseif (!$isArray) {
169225
$this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'";
170226
return false;
171227
}
@@ -182,14 +238,24 @@ private function validateOperatorForAttribute(
182238

183239
break;
184240
case DatabaseOperator::TYPE_ARRAY_UNIQUE:
185-
if (!$isArray) {
241+
if ($type === Database::VAR_RELATIONSHIP) {
242+
if (!$this->isRelationshipArray($attribute)) {
243+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
244+
return false;
245+
}
246+
} elseif (!$isArray) {
186247
$this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'";
187248
return false;
188249
}
189250

190251
break;
191252
case DatabaseOperator::TYPE_ARRAY_INSERT:
192-
if (!$isArray) {
253+
if ($type === Database::VAR_RELATIONSHIP) {
254+
if (!$this->isRelationshipArray($attribute)) {
255+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
256+
return false;
257+
}
258+
} elseif (!$isArray) {
193259
$this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'";
194260
return false;
195261
}
@@ -206,6 +272,14 @@ private function validateOperatorForAttribute(
206272
}
207273

208274
$insertValue = $values[1];
275+
276+
if ($type === Database::VAR_RELATIONSHIP) {
277+
if (!$this->isValidRelationshipValue($insertValue)) {
278+
$this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects";
279+
return false;
280+
}
281+
}
282+
209283
if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) {
210284
if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) {
211285
$this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT;
@@ -228,7 +302,19 @@ private function validateOperatorForAttribute(
228302

229303
break;
230304
case DatabaseOperator::TYPE_ARRAY_REMOVE:
231-
if (!$isArray) {
305+
if ($type === Database::VAR_RELATIONSHIP) {
306+
if (!$this->isRelationshipArray($attribute)) {
307+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
308+
return false;
309+
}
310+
$toValidate = \is_array($values[0]) ? $values[0] : $values;
311+
foreach ($toValidate as $item) {
312+
if (!$this->isValidRelationshipValue($item)) {
313+
$this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects";
314+
return false;
315+
}
316+
}
317+
} elseif (!$isArray) {
232318
$this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'";
233319
return false;
234320
}
@@ -240,7 +326,12 @@ private function validateOperatorForAttribute(
240326

241327
break;
242328
case DatabaseOperator::TYPE_ARRAY_INTERSECT:
243-
if (!$isArray) {
329+
if ($type === Database::VAR_RELATIONSHIP) {
330+
if (!$this->isRelationshipArray($attribute)) {
331+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
332+
return false;
333+
}
334+
} elseif (!$isArray) {
244335
$this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'";
245336
return false;
246337
}
@@ -250,16 +341,41 @@ private function validateOperatorForAttribute(
250341
return false;
251342
}
252343

344+
if ($type === Database::VAR_RELATIONSHIP) {
345+
foreach ($values as $item) {
346+
if (!$this->isValidRelationshipValue($item)) {
347+
$this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects";
348+
return false;
349+
}
350+
}
351+
}
352+
253353
break;
254354
case DatabaseOperator::TYPE_ARRAY_DIFF:
255-
if (!$isArray) {
355+
if ($type === Database::VAR_RELATIONSHIP) {
356+
if (!$this->isRelationshipArray($attribute)) {
357+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
358+
return false;
359+
}
360+
foreach ($values as $item) {
361+
if (!$this->isValidRelationshipValue($item)) {
362+
$this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects";
363+
return false;
364+
}
365+
}
366+
} elseif (!$isArray) {
256367
$this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'";
257368
return false;
258369
}
259370

260371
break;
261372
case DatabaseOperator::TYPE_ARRAY_FILTER:
262-
if (!$isArray) {
373+
if ($type === Database::VAR_RELATIONSHIP) {
374+
if (!$this->isRelationshipArray($attribute)) {
375+
$this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'";
376+
return false;
377+
}
378+
} elseif (!$isArray) {
263379
$this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'";
264380
return false;
265381
}

0 commit comments

Comments
 (0)