diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 734312b7e..47ef27bcf 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -42,6 +42,7 @@ class Mongo extends Adapter '$regex', '$not', '$nor', + '$exists', ]; protected Client $client; @@ -2373,6 +2374,8 @@ protected function buildFilter(Query $query): array $value = match ($query->getMethod()) { Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL => null, + Query::TYPE_EXISTS => true, + Query::TYPE_NOT_EXISTS => false, default => $this->getQueryValue( $query->getMethod(), count($query->getValues()) > 1 @@ -2434,6 +2437,10 @@ protected function buildFilter(Query $query): array $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')]; } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; + } elseif ($operator === '$exists') { + foreach ($query->getValues() as $attribute) { + $filter['$or'][] = [$attribute => [$operator => $value]]; + } } else { $filter[$attribute][$operator] = $value; } @@ -2472,6 +2479,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_NOT_ENDS_WITH => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => '$exists', default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT), }; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 44d028d99..bebb1db61 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -30,6 +30,7 @@ */ class Postgres extends SQL { + public const MAX_IDENTIFIER_NAME = 63; /** * @inheritDoc */ @@ -244,17 +245,24 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { + $uidIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_uid"); + $createdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_created"); + $updatedIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_updated"); + $tenantIdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_tenant_id"); $collection .= " - CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_created\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_updated\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_tenant_id\" ON {$this->getSQLTable($id)} (_tenant, _id); + CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); + CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); + CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); + CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id); "; } else { + $uidIndex = $this->getShortKey("{$namespace}_{$id}_uid"); + $createdIndex = $this->getShortKey("{$namespace}_{$id}_created"); + $updatedIndex = $this->getShortKey("{$namespace}_{$id}_updated"); $collection .= " - CREATE UNIQUE INDEX \"{$namespace}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); - CREATE INDEX \"{$namespace}_{$id}_created\" ON {$this->getSQLTable($id)} (\"_createdAt\"); - CREATE INDEX \"{$namespace}_{$id}_updated\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); + CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); + CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\"); + CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); "; } @@ -271,17 +279,21 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { + $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); + $permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission"); $permissions .= " - CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_ukey\" + CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_permission\" + CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); "; } else { + $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey"); + $permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission"); $permissions .= " - CREATE UNIQUE INDEX \"{$namespace}_{$id}_ukey\" + CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission); - CREATE INDEX \"{$namespace}_{$id}_permission\" + CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type); "; } @@ -893,7 +905,7 @@ public function createIndex(string $collection, string $id, string $type, array default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT), }; - $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); $attributes = \implode(', ', $attributes); if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { @@ -901,7 +913,7 @@ public function createIndex(string $collection, string $id, string $type, array $attributes = "_tenant, {$attributes}"; } - $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)}"; + $sql = "CREATE {$sqlType} \"{$keyName}\" ON {$this->getSQLTable($collection)}"; // Add USING clause for special index types $sql .= match ($type) { @@ -936,9 +948,9 @@ public function deleteIndex(string $collection, string $id): bool $id = $this->filter($id); $schemaName = $this->getDatabase(); - $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); - $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".{$key}"; + $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\""; $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); return $this->execute($this->getPDO() @@ -961,10 +973,11 @@ public function renameIndex(string $collection, string $old, string $new): bool $namespace = $this->getNamespace(); $old = $this->filter($old); $new = $this->filter($new); - $oldIndexName = "{$this->tenant}_{$collection}_{$old}"; - $newIndexName = "{$namespace}_{$this->tenant}_{$collection}_{$new}"; + $schema = $this->getDatabase(); + $oldIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$old}"); + $newIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$new}"); - $sql = "ALTER INDEX {$this->getSQLTable($oldIndexName)} RENAME TO \"{$newIndexName}\""; + $sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\""; $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); return $this->execute($this->getPDO() @@ -2743,4 +2756,42 @@ public function getSupportNonUtfCharacters(): bool { return false; } + + /** + * Ensure index key length stays within PostgreSQL's 63 character limit. + * + * @param string $key + * @return string + */ + protected function getShortKey(string $key): string + { + if (\strlen($key) <= self::MAX_IDENTIFIER_NAME) { + return $key; + } + + $suffix = ''; + $separatorPosition = strrpos($key, '_'); + if ($separatorPosition !== false) { + $suffix = substr($key, $separatorPosition + 1); + } + + $hash = md5($key); + + if ($suffix !== '') { + $hashedKey = "{$hash}_{$suffix}"; + if (\strlen($hashedKey) <= self::MAX_IDENTIFIER_NAME) { + return $hashedKey; + } + } + + return substr($hash, 0, self::MAX_IDENTIFIER_NAME); + } + + protected function getSQLTable(string $name): string + { + $table = "{$this->getNamespace()}_{$this->filter($name)}"; + $table = $this->getShortKey($table); + + return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9fb62db3a..72a624972 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1797,6 +1797,9 @@ protected function getSQLOperator(string $method): string case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: throw new DatabaseException('Vector queries are not supported by this database'); + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: + throw new DatabaseException('Exists queries are not supported by this database'); default: throw new DatabaseException('Unknown method: ' . $method); } diff --git a/src/Database/Database.php b/src/Database/Database.php index 5fe03cc54..7f62679e8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3003,14 +3003,14 @@ public function checkAttribute(Document $collection, Document $attribute): bool $this->adapter->getLimitForAttributes() > 0 && $this->adapter->getCountOfAttributes($collection) > $this->adapter->getLimitForAttributes() ) { - throw new LimitException('Column limit reached. Cannot create new attribute.'); + throw new LimitException('Column limit reached. Cannot create new attribute. Current attribute count is ' . $this->adapter->getCountOfAttributes($collection) . ' but the maximum is ' . $this->adapter->getLimitForAttributes() . '. Remove some attributes to free up space.'); } if ( $this->adapter->getDocumentSizeLimit() > 0 && $this->adapter->getAttributeWidth($collection) >= $this->adapter->getDocumentSizeLimit() ) { - throw new LimitException('Row width limit reached. Cannot create new attribute.'); + throw new LimitException('Row width limit reached. Cannot create new attribute. Current row width is ' . $this->adapter->getAttributeWidth($collection) . ' bytes but the maximum is ' . $this->adapter->getDocumentSizeLimit() . ' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'); } return true; @@ -5671,6 +5671,11 @@ public function updateDocument(string $collection, string $id, Document $documen break; } + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + if (!\is_array($value) || !\array_is_list($value)) { throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.'); } @@ -6079,6 +6084,24 @@ private function updateDocumentRelationships(Document $collection, Document $old $twoWayKey = (string)$relationship['options']['twoWayKey']; $side = (string)$relationship['options']['side']; + if (Operator::isOperator($value)) { + $operator = $value; + if ($operator->isArrayOperation()) { + $existingIds = []; + if (\is_array($oldValue)) { + $existingIds = \array_map(function ($item) { + if ($item instanceof Document) { + return $item->getId(); + } + return $item; + }, $oldValue); + } + + $value = $this->applyRelationshipOperator($operator, $existingIds); + $document->setAttribute($key, $value); + } + } + if ($oldValue == $value) { if ( ($relationType === Database::RELATION_ONE_TO_ONE @@ -6438,6 +6461,63 @@ private function getJunctionCollection(Document $collection, Document $relatedCo : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); } + /** + * Apply an operator to a relationship array of IDs + * + * @param Operator $operator + * @param array $existingIds + * @return array + */ + private function applyRelationshipOperator(Operator $operator, array $existingIds): array + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + // Extract IDs from operator values (could be strings or Documents) + $valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values)); + + switch ($method) { + case Operator::TYPE_ARRAY_APPEND: + return \array_values(\array_merge($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_PREPEND: + return \array_values(\array_merge($valueIds, $existingIds)); + + case Operator::TYPE_ARRAY_INSERT: + $index = $values[0] ?? 0; + $item = $values[1] ?? null; + $itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null); + if ($itemId !== null) { + \array_splice($existingIds, $index, 0, [$itemId]); + } + return \array_values($existingIds); + + case Operator::TYPE_ARRAY_REMOVE: + $toRemove = $values[0] ?? null; + if (\is_array($toRemove)) { + $toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove)); + return \array_values(\array_diff($existingIds, $toRemoveIds)); + } + $toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null); + if ($toRemoveId !== null) { + return \array_values(\array_diff($existingIds, [$toRemoveId])); + } + return $existingIds; + + case Operator::TYPE_ARRAY_UNIQUE: + return \array_values(\array_unique($existingIds)); + + case Operator::TYPE_ARRAY_INTERSECT: + return \array_values(\array_intersect($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_DIFF: + return \array_values(\array_diff($existingIds, $valueIds)); + + default: + return $existingIds; + } + } + /** * Create or update a document. * diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..e8ccdcaa3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -26,6 +26,8 @@ class Query public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_EXISTS = 'exists'; + public const TYPE_NOT_EXISTS = 'notExists'; // Spatial methods public const TYPE_CROSSES = 'crosses'; @@ -99,6 +101,8 @@ class Query self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS, self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, @@ -294,7 +298,9 @@ public static function isMethod(string $value): bool self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN => true, + self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS => true, default => false, }; } @@ -1178,4 +1184,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self { return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + + /** + * Helper method to create Query with exists method + * + * @param array $attributes + * @return Query + */ + public static function exists(array $attributes): self + { + return new self(self::TYPE_EXISTS, '', $attributes); + } + + /** + * Helper method to create Query with notExists method + * + * @param string|int|float|bool|array $attribute + * @return Query + */ + public static function notExists(string|int|float|bool|array $attribute): self + { + return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); + } } diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index e43ebf26c..842a4861e 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -36,6 +36,50 @@ public function __construct(Document $collection, ?Document $currentDocument = n } } + /** + * Check if a value is a valid relationship reference (string ID or Document) + * + * @param mixed $item + * @return bool + */ + private function isValidRelationshipValue(mixed $item): bool + { + return \is_string($item) || $item instanceof Document; + } + + /** + * Check if a relationship attribute represents a "many" side (returns array of documents) + * + * @param Document|array $attribute + * @return bool + */ + private function isRelationshipArray(Document|array $attribute): bool + { + $options = $attribute instanceof Document + ? $attribute->getAttribute('options', []) + : ($attribute['options'] ?? []); + + $relationType = $options['relationType'] ?? ''; + $side = $options['side'] ?? ''; + + // Many-to-many is always an array on both sides + if ($relationType === Database::RELATION_MANY_TO_MANY) { + return true; + } + + // One-to-many: array on parent side, single on child side + if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) { + return true; + } + + // Many-to-one: array on child side, single on parent side + if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) { + return true; + } + + return false; + } + /** * Get Description * @@ -165,7 +209,19 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_APPEND: case DatabaseOperator::TYPE_ARRAY_PREPEND: - if (!$isArray) { + // For relationships, check if it's a "many" side + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -182,14 +238,24 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_UNIQUE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_INSERT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -206,6 +272,14 @@ private function validateOperatorForAttribute( } $insertValue = $values[1]; + + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isValidRelationshipValue($insertValue)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) { if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) { $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( break; case DatabaseOperator::TYPE_ARRAY_REMOVE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + $toValidate = \is_array($values[0]) ? $values[0] : $values; + foreach ($toValidate as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -240,7 +326,12 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_INTERSECT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } @@ -250,16 +341,41 @@ private function validateOperatorForAttribute( return false; } + if ($type === Database::VAR_RELATIONSHIP) { + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } + break; case DatabaseOperator::TYPE_ARRAY_DIFF: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_FILTER: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..22017692a 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -121,7 +121,9 @@ public function isValid($value): bool Query::TYPE_NOT_TOUCHES, Query::TYPE_VECTOR_DOT, Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, + Query::TYPE_VECTOR_EUCLIDEAN, + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..e62fc3913 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -91,6 +91,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attribute = \explode('.', $attribute)[0]; } + // exists and notExists queries don't require values, just attribute validation + if (in_array($method, [Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS])) { + // Validate attribute (handles encrypted attributes, schemaless mode, etc.) + return $this->isValidAttribute($attribute); + } + if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { // First check maxValuesCount guard for any IN-style value arrays if (count($values) > $this->maxValuesCount) { @@ -250,7 +256,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) ) { $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; return false; @@ -306,6 +312,8 @@ public function isValid($value): bool case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: case Query::TYPE_NOT_CONTAINS: + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: if ($this->isEmpty($value->getValues())) { $this->message = \ucfirst($method) . ' queries require at least one value.'; return false; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index f62c94fe8..d4465226e 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -959,7 +959,8 @@ public function testExceptionAttributeLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); } try { @@ -967,7 +968,8 @@ public function testExceptionAttributeLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); } } @@ -1037,7 +1039,9 @@ public function testExceptionWidthLimit(): void $this->fail('Failed to throw exception'); } catch (\Exception $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } try { @@ -1045,7 +1049,9 @@ public function testExceptionWidthLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index ce809f426..ccf884f5c 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1581,4 +1581,101 @@ public function testSetGlobalCollection(): void $this->assertEmpty($db->getGlobalCollections()); } + + public function testCreateCollectionWithLongId(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = '019a91aa-58cd-708d-a55c-5f7725ef937a'; + + $attributes = [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'array' => false, + ]), + new Document([ + '$id' => 'age', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => false, + 'array' => false, + ]), + new Document([ + '$id' => 'isActive', + 'type' => Database::VAR_BOOLEAN, + 'size' => 0, + 'required' => false, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + '$id' => ID::custom('idx_name'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + 'lengths' => [128], + 'orders' => ['ASC'], + ]), + new Document([ + '$id' => ID::custom('idx_name_age'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name', 'age'], + 'lengths' => [128, null], + 'orders' => ['ASC', 'DESC'], + ]), + ]; + + $collectionDocument = $database->createCollection( + $collection, + $attributes, + $indexes, + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ); + + $this->assertEquals($collection, $collectionDocument->getId()); + $this->assertCount(3, $collectionDocument->getAttribute('attributes')); + $this->assertCount(2, $collectionDocument->getAttribute('indexes')); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'longIdDoc', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'LongId Test', + 'age' => 42, + 'isActive' => true, + ])); + + $this->assertEquals('longIdDoc', $document->getId()); + $this->assertEquals('LongId Test', $document->getAttribute('name')); + $this->assertEquals(42, $document->getAttribute('age')); + $this->assertTrue($document->getAttribute('isActive')); + + $found = $database->find($collection, [ + Query::equal('name', ['LongId Test']), + ]); + + $this->assertCount(1, $found); + $this->assertEquals('longIdDoc', $found[0]->getId()); + + $fetched = $database->getDocument($collection, 'longIdDoc'); + $this->assertEquals('LongId Test', $fetched->getAttribute('name')); + + $this->assertTrue($database->deleteCollection($collection)); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 4df1ad461..ad7bbe5ed 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2093,4 +2093,161 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void $database->deleteCollection('tags'); $database->deleteCollection('articles'); } + + public function testManyToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('library'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('book'); + } catch (\Throwable $e) { + } + + $database->createCollection('library'); + $database->createCollection('book'); + + $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('book', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'library', + relatedCollection: 'book', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'books', + twoWayKey: 'libraries' + ); + + // Create some books + $book1 = $database->createDocument('book', new Document([ + '$id' => 'book1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 1', + ])); + + $book2 = $database->createDocument('book', new Document([ + '$id' => 'book2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 2', + ])); + + $book3 = $database->createDocument('book', new Document([ + '$id' => 'book3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 3', + ])); + + $book4 = $database->createDocument('book', new Document([ + '$id' => 'book4', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 4', + ])); + + // Create library with one book + $library = $database->createDocument('library', new Document([ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Library 1', + 'books' => ['book1'], + ])); + + $this->assertCount(1, $library->getAttribute('books')); + $this->assertEquals('book1', $library->getAttribute('books')[0]->getId()); + + // Test arrayAppend - add a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Test arrayAppend - add multiple books + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(4, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove('book2'), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(3, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove multiple books at once + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(1, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book3', $bookIds); + $this->assertNotContains('book4', $bookIds); + + // Test arrayPrepend - add books + // Note: Order is not guaranteed for many-to-many relationships as they use junction tables + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayPrepend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Cleanup + $database->deleteCollection('library'); + $database->deleteCollection('book'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 97b4cca4e..7923191cd 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -2676,4 +2676,191 @@ public function testPartialUpdateWithStringIdsVsDocuments(): void $database->deleteCollection('libraries'); $database->deleteCollection('books_lib'); } + + public function testOneToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('author'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('article'); + } catch (\Throwable $e) { + } + + $database->createCollection('author'); + $database->createCollection('article'); + + $database->createAttribute('author', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('article', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'author', + relatedCollection: 'article', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'articles', + twoWayKey: 'author' + ); + + // Create some articles + $article1 = $database->createDocument('article', new Document([ + '$id' => 'article1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 1', + ])); + + $article2 = $database->createDocument('article', new Document([ + '$id' => 'article2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 2', + ])); + + $article3 = $database->createDocument('article', new Document([ + '$id' => 'article3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 3', + ])); + + // Create author with one article + $database->createDocument('author', new Document([ + '$id' => 'author1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Author 1', + 'articles' => ['article1'], + ])); + + // Fetch the document to get relationships (needed for Mirror which may not return relationships on create) + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $this->assertEquals('article1', $author->getAttribute('articles')[0]->getId()); + + // Test arrayAppend - add articles + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayAppend(['article2']), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(2, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Test arrayRemove - remove an article + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayRemove('article1'), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertNotContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Cleanup + $database->deleteCollection('author'); + $database->deleteCollection('article'); + } + + public function testOneToManyChildSideRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('parent_o2m'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('child_o2m'); + } catch (\Throwable $e) { + } + + $database->createCollection('parent_o2m'); + $database->createCollection('child_o2m'); + + $database->createAttribute('parent_o2m', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('child_o2m', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'parent_o2m', + relatedCollection: 'child_o2m', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'children', + twoWayKey: 'parent' + ); + + // Create a parent + $database->createDocument('parent_o2m', new Document([ + '$id' => 'parent1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Parent 1', + ])); + + // Create child with parent + $database->createDocument('child_o2m', new Document([ + '$id' => 'child1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Child 1', + 'parent' => 'parent1', + ])); + + // Array operators should fail on child side (single-value "parent" relationship) + try { + $database->updateDocument('child_o2m', 'child1', new Document([ + 'parent' => \Utopia\Database\Operator::arrayAppend(['parent2']), + ])); + $this->fail('Expected exception for array operator on child side of one-to-many relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('parent_o2m'); + $database->deleteCollection('child_o2m'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 56f2ba5c5..e67c41138 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -2597,4 +2597,80 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void $database->deleteCollection('cities_strict'); $database->deleteCollection('mayors_strict'); } + + public function testOneToOneRelationshipRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('user_o2o'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('profile_o2o'); + } catch (\Throwable $e) { + } + + $database->createCollection('user_o2o'); + $database->createCollection('profile_o2o'); + + $database->createAttribute('user_o2o', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('profile_o2o', 'bio', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'user_o2o', + relatedCollection: 'profile_o2o', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + // Create a profile + $database->createDocument('profile_o2o', new Document([ + '$id' => 'profile1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'bio' => 'Test bio', + ])); + + // Create user with profile + $database->createDocument('user_o2o', new Document([ + '$id' => 'user1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'User 1', + 'profile' => 'profile1', + ])); + + // Array operators should fail on one-to-one relationships + try { + $database->updateDocument('user_o2o', 'user1', new Document([ + 'profile' => \Utopia\Database\Operator::arrayAppend(['profile2']), + ])); + $this->fail('Expected exception for array operator on one-to-one relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('user_o2o'); + $database->deleteCollection('profile_o2o'); + } } diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 436be6edd..fbcc7364c 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1154,4 +1154,227 @@ public function testSchemalessDates(): void $database->deleteCollection($col); } + + public function testSchemalessExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test exists - should return documents where optionalField exists (even if null) + $documents = $database->find($colName, [ + Query::exists(['optionalField']), + ]); + + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc1', $ids); + $this->assertContains('doc2', $ids); + $this->assertContains('doc4', $ids); + + // Verify that doc4 is included even though optionalField is null + $doc4 = array_filter($documents, fn ($doc) => $doc->getId() === 'doc4'); + $this->assertCount(1, $doc4); + $doc4Array = array_values($doc4); + $this->assertTrue(array_key_exists('optionalField', $doc4Array[0]->getAttributes())); + + // Test exists with another attribute + $documents = $database->find($colName, [ + Query::exists(['name']), + ]); + $this->assertEquals(5, count($documents)); // All documents have 'name' + + // Test exists with non-existent attribute + $documents = $database->find($colName, [ + Query::exists(['nonExistentField']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes in a single exists query (OR semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'name']), + ]); + // All documents have "name", some also have "optionalField" + $this->assertEquals(5, count($documents)); + + // Multiple attributes where only one exists on some documents + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'nonExistentField']), + ]); + // Only documents where optionalField exists should be returned + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Multiple attributes where none exist should return empty + $documents = $database->find($colName, [ + Query::exists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes including one present on all docs still returns all (OR) + $documents = $database->find($colName, [ + Query::exists(['name', 'nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple exists queries (AND semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField']), + Query::exists(['name']), + ]); + // Documents must have both attributes + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with exists (optionalField OR nonExistentField) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::exists(['optionalField']), + Query::exists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with only missing attributes should yield empty + $documents = $database->find($colName, [ + Query::or([ + Query::exists(['nonExistentField']), + Query::exists(['alsoMissing']), + ]), + ]); + $this->assertEquals(0, count($documents)); + + $database->deleteCollection($colName); + } + + public function testSchemalessNotExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_not_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test notExists - should return documents where optionalField does not exist + $documents = $database->find($colName, [ + Query::notExists('optionalField'), + ]); + + $this->assertEquals(2, count($documents)); // doc3, doc5 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc3', $ids); + $this->assertContains('doc5', $ids); + + // Verify that doc4 is NOT included (it exists even though null) + $this->assertNotContains('doc4', $ids); + + // Test notExists with another attribute + $documents = $database->find($colName, [ + Query::notExists('name'), + ]); + $this->assertEquals(0, count($documents)); // All documents have 'name' + + // Test notExists with non-existent attribute + $documents = $database->find($colName, [ + Query::notExists('nonExistentField'), + ]); + $this->assertEquals(5, count($documents)); // All documents don't have this field + + // Multiple attributes in a single notExists query (OR semantics) - both missing + $documents = $database->find($colName, [ + Query::notExists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple attributes (OR) where only some documents miss one of them + $documents = $database->find($colName, [ + Query::notExists(['name', 'optionalField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + // Multiple notExists queries (AND semantics) - must miss both + $documents = $database->find($colName, [ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + // Test combination of exists and notExists + $documents = $database->find($colName, [ + Query::exists(['name']), + Query::notExists('optionalField'), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + // Nested OR/AND with notExists: (notExists optionalField OR notExists nonExistent) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + // notExists(nonExistentField) matches all docs, so OR is always true; AND with name returns all + $this->assertEquals(5, count($documents)); // all docs match due to nonExistentField + + // Nested OR with notExists where all attributes exist => empty + $documents = $database->find($colName, [ + Query::or([ + Query::notExists(['name']), + Query::notExists(['optionalField']), + ]), + ]); + $this->assertEquals(2, count($documents)); // only ones missing optionalField (doc3, doc5) + + $database->deleteCollection($colName); + } }