diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index de19db484..5f5846146 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1271,12 +1271,45 @@ abstract public function getInternalIndexesKeys(): array; */ abstract public function getSchemaAttributes(string $collection): array; + /** + * Get the query to check for tenant when in shared tables mode + * + * @param string $collection The collection being queried + * @param string $alias The alias of the parent collection if in a subquery + * @return string + */ + abstract public function getTenantQuery(string $collection, string $alias = ''): string; + /** * @param mixed $stmt * @return bool */ abstract protected function execute(mixed $stmt): bool; + /** + * Decode a WKB or textual POINT into [x, y] + * + * @param string $wkb + * @return float[] Array with two elements: [x, y] + */ + abstract public function decodePoint(string $wkb): array; + + /** + * Decode a WKB or textual LINESTRING into [[x1, y1], [x2, y2], ...] + * + * @param string $wkb + * @return float[][] Array of points, each as [x, y] + */ + abstract public function decodeLinestring(string $wkb): array; + + /** + * Decode a WKB or textual POLYGON into [[[x1, y1], [x2, y2], ...], ...] + * + * @param string $wkb + * @return float[][][] Array of rings, each ring is an array of points [x, y] + */ + abstract public function decodePolygon(string $wkb): array; + /** * Returns the document after casting * @param Document $collection diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index c78d6637c..5e8f15369 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -155,7 +155,7 @@ public function createCollection(string $name, array $attributes = [], array $in $collection = " CREATE TABLE {$this->getSQLTable($id)} ( - _id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, _uid VARCHAR(255) NOT NULL, _createdAt DATETIME(3) DEFAULT NULL, _updatedAt DATETIME(3) DEFAULT NULL, @@ -186,7 +186,7 @@ public function createCollection(string $name, array $attributes = [], array $in $permissions = " CREATE TABLE {$this->getSQLTable($id . '_perms')} ( - _id int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, _document VARCHAR(255) NOT NULL, diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index dcb920144..9b5efd93c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -31,16 +31,24 @@ class Mongo extends Adapter '$gt', '$gte', '$in', + '$nin', '$text', '$search', '$or', '$and', '$match', '$regex', + '$not', + '$nor', ]; protected Client $client; + /** + * Default batch size for cursor operations + */ + private const DEFAULT_BATCH_SIZE = 1000; + //protected ?int $timeout = null; /** @@ -1277,15 +1285,43 @@ public function getSequences(string $collection, array $documents): array $filters['_tenant'] = $this->getTenantFilters($collection, $documentTenants); } try { - $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + // Use cursor paging for large result sets + $options = [ + 'projection' => ['_uid' => 1, '_id' => 1], + 'batchSize' => self::DEFAULT_BATCH_SIZE + ]; + + $response = $this->client->find($name, $filters, $options); + $results = $response->cursor->firstBatch ?? []; + + // Process first batch + foreach ($results as $result) { + $sequences[$result->_uid] = (string)$result->_id; + } + + // Get cursor ID for subsequent batches + $cursorId = $response->cursor->id ?? null; + + // Continue fetching with getMore + while ($cursorId && $cursorId !== 0) { + $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); + $moreResults = $moreResponse->cursor->nextBatch ?? []; + + if (empty($moreResults)) { + break; + } + + foreach ($moreResults as $result) { + $sequences[$result->_uid] = (string)$result->_id; + } + + // Update cursor ID for next iteration + $cursorId = (int)($moreResponse->cursor->id ?? 0); + } } catch (MongoException $e) { throw $this->processException($e); } - foreach ($results->cursor->firstBatch as $result) { - $sequences[$result->_uid] = (string)$result->_id; - } - foreach ($documents as $document) { if (isset($sequences[$document->getId()])) { $document['$sequence'] = $sequences[$document->getId()]; @@ -1575,12 +1611,10 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 try { // Use proper cursor iteration with reasonable batch size - $batchSize = 1000; - $options['batchSize'] = $batchSize; + $options['batchSize'] = self::DEFAULT_BATCH_SIZE; $response = $this->client->find($name, $filters, $options); $results = $response->cursor->firstBatch ?? []; - // Process first batch foreach ($results as $result) { $record = $this->replaceChars('_', '$', (array)$result); @@ -1597,7 +1631,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 break; } - $moreResponse = $this->client->getMore($cursorId, $name, $batchSize); + $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); $moreResults = $moreResponse->cursor->nextBatch ?? []; if (empty($moreResults)) { @@ -1614,7 +1648,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } } - $cursorId = $moreResponse->cursor->id ?? 0; + $cursorId = (int)($moreResponse->cursor->id ?? 0); } } catch (MongoException $e) { @@ -1985,11 +2019,38 @@ protected function buildFilter(Query $query): array } else { $filter[$attribute]['$in'] = $query->getValues(); } + } elseif ($operator === 'notContains') { + if (!$query->onArray()) { + $filter[$attribute] = ['$not' => new Regex(".*{$this->escapeWildcards($value)}.*", 'i')]; + } else { + $filter[$attribute]['$nin'] = $query->getValues(); + } } elseif ($operator == '$search') { - $filter['$text'][$operator] = $value; + if ($query->getMethod() === Query::TYPE_NOT_SEARCH) { + // MongoDB doesn't support negating $text expressions directly + // Use regex as fallback for NOT search while keeping fulltext for positive search + if (empty($value)) { + // If value is not passed, don't add any filter - this will match all documents + } else { + // Escape special regex characters and create a pattern that matches the search term as substring + $escapedValue = preg_quote($value, '/'); + $filter[$attribute] = ['$not' => new Regex(".*{$escapedValue}.*", 'i')]; + } + } else { + $filter['$text'][$operator] = $value; + } } elseif ($operator === Query::TYPE_BETWEEN) { $filter[$attribute]['$lte'] = $value[1]; $filter[$attribute]['$gte'] = $value[0]; + } elseif ($operator === Query::TYPE_NOT_BETWEEN) { + $filter['$or'] = [ + [$attribute => ['$lt' => $value[0]]], + [$attribute => ['$gt' => $value[1]]] + ]; + } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_STARTS_WITH) { + $filter[$attribute] = ['$not' => new Regex('^' . $value, 'i')]; + } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { + $filter[$attribute] = ['$not' => new Regex($value . '$', 'i')]; } else { $filter[$attribute][$operator] = $value; } @@ -2017,13 +2078,18 @@ protected function getQueryOperator(string $operator): string Query::TYPE_GREATER => '$gt', Query::TYPE_GREATER_EQUAL => '$gte', Query::TYPE_CONTAINS => '$in', + Query::TYPE_NOT_CONTAINS => 'notContains', Query::TYPE_SEARCH => '$search', + Query::TYPE_NOT_SEARCH => '$search', Query::TYPE_BETWEEN => 'between', + Query::TYPE_NOT_BETWEEN => 'notBetween', Query::TYPE_STARTS_WITH, - Query::TYPE_ENDS_WITH => '$regex', + Query::TYPE_NOT_STARTS_WITH, + Query::TYPE_ENDS_WITH, + Query::TYPE_NOT_ENDS_WITH => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', - 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_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_SELECT), + 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), }; } @@ -2033,9 +2099,15 @@ protected function getQueryValue(string $method, mixed $value): mixed case Query::TYPE_STARTS_WITH: $value = $this->escapeWildcards($value); return $value . '.*'; + case Query::TYPE_NOT_STARTS_WITH: + $value = $this->escapeWildcards($value); + return $value . '.*'; case Query::TYPE_ENDS_WITH: $value = $this->escapeWildcards($value); return '.*' . $value; + case Query::TYPE_NOT_ENDS_WITH: + $value = $this->escapeWildcards($value); + return '.*' . $value; default: return $value; } @@ -2232,7 +2304,7 @@ public function getSupportForFulltextWildcardIndex(): bool */ public function getSupportForQueryContains(): bool { - return false; + return true; } /** @@ -2628,4 +2700,45 @@ public function getTenantFilters( return ['$in' => $values]; } + + public function decodePoint(string $wkb): array + { + return []; + } + + /** + * Decode a WKB or textual LINESTRING into [[x1, y1], [x2, y2], ...] + * + * @param string $wkb + * @return float[][] Array of points, each as [x, y] + */ + public function decodeLinestring(string $wkb): array + { + return []; + } + + /** + * Decode a WKB or textual POLYGON into [[[x1, y1], [x2, y2], ...], ...] + * + * @param string $wkb + * @return float[][][] Array of rings, each ring is an array of points [x, y] + */ + public function decodePolygon(string $wkb): array + { + return []; + } + + /** + * Get the query to check for tenant when in shared tables mode + * + * @param string $collection The collection being queried + * @param string $alias The alias of the parent collection if in a subquery + * @return string + */ + public function getTenantQuery(string $collection, string $alias = ''): string + { + return ''; + } + + } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index c8d909aca..4c43f8536 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -470,13 +470,7 @@ public function getKeywords(): array return $this->delegate(__FUNCTION__, \func_get_args()); } - /** - * @param array $selections - * @param string $prefix - * @param array $spatialAttributes - * @return mixed - */ - protected function getAttributeProjection(array $selections, string $prefix, array $spatialAttributes = []): mixed + protected function getAttributeProjection(array $selections, string $prefix): mixed { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -550,6 +544,21 @@ public function getSupportForSpatialAxisOrder(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function decodePoint(string $wkb): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function decodeLinestring(string $wkb): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function decodePolygon(string $wkb): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function castingBefore(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index f666d1184..25f9ffc34 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -232,14 +232,13 @@ public function createCollection(string $name, array $attributes = [], array $in $sqlTenant = $this->sharedTables ? '_tenant INTEGER DEFAULT NULL,' : ''; $collection = " CREATE TABLE {$this->getSQLTable($id)} ( - _id SERIAL NOT NULL, + _id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, _uid VARCHAR(255) NOT NULL, " . $sqlTenant . " \"_createdAt\" TIMESTAMP(3) DEFAULT NULL, \"_updatedAt\" TIMESTAMP(3) DEFAULT NULL, - _permissions TEXT DEFAULT NULL, " . \implode(' ', $attributeStrings) . " - PRIMARY KEY (_id) + _permissions TEXT DEFAULT NULL ); "; @@ -262,13 +261,12 @@ public function createCollection(string $name, array $attributes = [], array $in $permissions = " CREATE TABLE {$this->getSQLTable($id . '_perms')} ( - _id SERIAL NOT NULL, + _id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, _tenant INTEGER DEFAULT NULL, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, - _document VARCHAR(255) NOT NULL, - PRIMARY KEY (_id) - ); + _document VARCHAR(255) NOT NULL + ); "; if ($this->sharedTables) { @@ -2002,4 +2000,243 @@ public function getSupportForSpatialAxisOrder(): bool { return false; } + + public function decodePoint(string $wkb): array + { + if (str_starts_with(strtoupper($wkb), 'POINT(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); + + $coords = explode(' ', trim($inside)); + return [(float)$coords[0], (float)$coords[1]]; + } + + $bin = hex2bin($wkb); + if ($bin === false) { + throw new DatabaseException('Invalid hex WKB string'); + } + + if (strlen($bin) < 13) { // 1 byte endian + 4 bytes type + 8 bytes for X + throw new DatabaseException('WKB too short'); + } + + $isLE = ord($bin[0]) === 1; + + // Type (4 bytes) + $typeBytes = substr($bin, 1, 4); + if (strlen($typeBytes) !== 4) { + throw new DatabaseException('Failed to extract type bytes from WKB'); + } + + $typeArr = unpack($isLE ? 'V' : 'N', $typeBytes); + if ($typeArr === false || !isset($typeArr[1])) { + throw new DatabaseException('Failed to unpack type from WKB'); + } + $type = $typeArr[1]; + + // Offset to coordinates (skip SRID if present) + $offset = 5 + (($type & 0x20000000) ? 4 : 0); + + if (strlen($bin) < $offset + 16) { // 16 bytes for X,Y + throw new DatabaseException('WKB too short for coordinates'); + } + + $fmt = $isLE ? 'e' : 'E'; // little vs big endian double + + // X coordinate + $xArr = unpack($fmt, substr($bin, $offset, 8)); + if ($xArr === false || !isset($xArr[1])) { + throw new DatabaseException('Failed to unpack X coordinate'); + } + $x = (float)$xArr[1]; + + // Y coordinate + $yArr = unpack($fmt, substr($bin, $offset + 8, 8)); + if ($yArr === false || !isset($yArr[1])) { + throw new DatabaseException('Failed to unpack Y coordinate'); + } + $y = (float)$yArr[1]; + + return [$x, $y]; + } + + public function decodeLinestring(mixed $wkb): array + { + if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); + + $points = explode(',', $inside); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + } + + if (ctype_xdigit($wkb)) { + $wkb = hex2bin($wkb); + if ($wkb === false) { + throw new DatabaseException("Failed to convert hex WKB to binary."); + } + } + + if (strlen($wkb) < 9) { + throw new DatabaseException("WKB too short to be a valid geometry"); + } + + $byteOrder = ord($wkb[0]); + if ($byteOrder === 0) { + throw new DatabaseException("Big-endian WKB not supported"); + } elseif ($byteOrder !== 1) { + throw new DatabaseException("Invalid byte order in WKB"); + } + + // Type + SRID flag + $typeField = unpack('V', substr($wkb, 1, 4)); + if ($typeField === false) { + throw new DatabaseException('Failed to unpack the type field from WKB.'); + } + + $typeField = $typeField[1]; + $geomType = $typeField & 0xFF; + $hasSRID = ($typeField & 0x20000000) !== 0; + + if ($geomType !== 2) { // 2 = LINESTRING + throw new DatabaseException("Not a LINESTRING geometry type, got {$geomType}"); + } + + $offset = 5; + if ($hasSRID) { + $offset += 4; + } + + $numPoints = unpack('V', substr($wkb, $offset, 4)); + if ($numPoints === false) { + throw new DatabaseException("Failed to unpack number of points at offset {$offset}."); + } + + $numPoints = $numPoints[1]; + $offset += 4; + + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $x = unpack('e', substr($wkb, $offset, 8)); + if ($x === false) { + throw new DatabaseException("Failed to unpack X coordinate at offset {$offset}."); + } + + $x = (float) $x[1]; + + $offset += 8; + + $y = unpack('e', substr($wkb, $offset, 8)); + if ($y === false) { + throw new DatabaseException("Failed to unpack Y coordinate at offset {$offset}."); + } + + $y = (float) $y[1]; + + $offset += 8; + $points[] = [$x, $y]; + } + + return $points; + } + + public function decodePolygon(string $wkb): array + { + // POLYGON((x1,y1),(x2,y2)) + if (str_starts_with($wkb, 'POLYGON((')) { + $start = strpos($wkb, '((') + 2; + $end = strrpos($wkb, '))'); + $inside = substr($wkb, $start, $end - $start); + + $rings = explode('),(', $inside); + return array_map(function ($ring) { + $points = explode(',', $ring); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + }, $rings); + } + + // Convert hex string to binary if needed + if (preg_match('/^[0-9a-fA-F]+$/', $wkb)) { + $wkb = hex2bin($wkb); + if ($wkb === false) { + throw new DatabaseException("Invalid hex WKB"); + } + } + + if (strlen($wkb) < 9) { + throw new DatabaseException("WKB too short"); + } + + $uInt32 = 'V'; // little-endian 32-bit unsigned + $uDouble = 'd'; // little-endian double + + $typeInt = unpack($uInt32, substr($wkb, 1, 4)); + if ($typeInt === false) { + throw new DatabaseException('Failed to unpack type field from WKB.'); + } + + $typeInt = (int) $typeInt[1]; + $hasSrid = ($typeInt & 0x20000000) !== 0; + $geomType = $typeInt & 0xFF; + + if ($geomType !== 3) { // 3 = POLYGON + throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + } + + $offset = 5; + if ($hasSrid) { + $offset += 4; + } + + // Number of rings + $numRings = unpack($uInt32, substr($wkb, $offset, 4)); + if ($numRings === false) { + throw new DatabaseException('Failed to unpack number of rings from WKB.'); + } + + $numRings = (int) $numRings[1]; + $offset += 4; + + $rings = []; + for ($r = 0; $r < $numRings; $r++) { + $numPoints = unpack($uInt32, substr($wkb, $offset, 4)); + if ($numPoints === false) { + throw new DatabaseException('Failed to unpack number of points from WKB.'); + } + + $numPoints = (int) $numPoints[1]; + $offset += 4; + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $x = unpack($uDouble, substr($wkb, $offset, 8)); + if ($x === false) { + throw new DatabaseException('Failed to unpack X coordinate from WKB.'); + } + + $x = (float) $x[1]; + + $y = unpack($uDouble, substr($wkb, $offset + 8, 8)); + if ($y === false) { + throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); + } + + $y = (float) $y[1]; + + $points[] = [$x, $y]; + $offset += 16; + } + $rings[] = $points; + } + + return $rings; // array of rings, each ring is array of [x,y] + } + } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 363107aa0..e6a77478e 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -350,7 +350,6 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa */ public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $name = $this->filter($collection); @@ -361,7 +360,7 @@ public function getDocument(Document $collection, string $id, array $queries = [ $alias = Query::DEFAULT_ALIAS; $sql = " - SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} + SELECT {$this->getAttributeProjection($selections, $alias)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid {$this->getTenantQuery($collection, $alias)} @@ -1907,37 +1906,13 @@ public function getTenantQuery( * * @param array $selections * @param string $prefix - * @param array $spatialAttributes * @return mixed * @throws Exception */ - protected function getAttributeProjection(array $selections, string $prefix, array $spatialAttributes = []): mixed + protected function getAttributeProjection(array $selections, string $prefix): mixed { if (empty($selections) || \in_array('*', $selections)) { - if (empty($spatialAttributes)) { - return "{$this->quote($prefix)}.*"; - } - - $projections = []; - $projections[] = "{$this->quote($prefix)}.*"; - - $internalColumns = ['_id', '_uid', '_createdAt', '_updatedAt', '_permissions']; - if ($this->sharedTables) { - $internalColumns[] = '_tenant'; - } - foreach ($internalColumns as $col) { - $projections[] = "{$this->quote($prefix)}.{$this->quote($col)}"; - } - - foreach ($spatialAttributes as $spatialAttr) { - $filteredAttr = $this->filter($spatialAttr); - $quotedAttr = $this->quote($filteredAttr); - $axisOrder = $this->getSupportForSpatialAxisOrder() ? ', ' . $this->getSpatialAxisOrderSpec() : ''; - $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedAttr} {$axisOrder} ) AS {$quotedAttr}"; - } - - - return implode(', ', $projections); + return "{$this->quote($prefix)}.*"; } // Handle specific selections with spatial conversion where needed @@ -1959,13 +1934,7 @@ protected function getAttributeProjection(array $selections, string $prefix, arr foreach ($selections as $selection) { $filteredSelection = $this->filter($selection); $quotedSelection = $this->quote($filteredSelection); - - if (in_array($selection, $spatialAttributes)) { - $axisOrder = $this->getSupportForSpatialAxisOrder() ? ', ' . $this->getSpatialAxisOrderSpec() : ''; - $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedSelection} {$axisOrder}) AS {$quotedSelection}"; - } else { - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; - } + $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; } return \implode(',', $projections); @@ -2409,7 +2378,6 @@ protected function getAttributeType(string $attributeName, array $attributes): ? */ public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array { - $spatialAttributes = $this->getSpatialAttributes($collection); $attributes = $collection->getAttribute('attributes', []); $collection = $collection->getId(); @@ -2517,7 +2485,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $sql = " - SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} + SELECT {$this->getAttributeProjection($selections, $alias)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} {$sqlWhere} {$sqlOrder} @@ -2740,4 +2708,195 @@ public function getSpatialTypeFromWKT(string $wkt): string } return strtolower(trim(substr($wkt, 0, $pos))); } + + public function decodePoint(string $wkb): array + { + if (str_starts_with(strtoupper($wkb), 'POINT(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); + $coords = explode(' ', trim($inside)); + return [(float)$coords[0], (float)$coords[1]]; + } + + /** + * [0..3] SRID (4 bytes, little-endian) + * [4] Byte order (1 = little-endian, 0 = big-endian) + * [5..8] Geometry type (with SRID flag bit) + * [9..] Geometry payload (coordinates, etc.) + */ + + if (strlen($wkb) < 25) { + throw new DatabaseException('Invalid WKB: too short for POINT'); + } + + // 4 bytes SRID first → skip to byteOrder at offset 4 + $byteOrder = ord($wkb[4]); + $littleEndian = ($byteOrder === 1); + + if (!$littleEndian) { + throw new DatabaseException('Only little-endian WKB supported'); + } + + // After SRID (4) + byteOrder (1) + type (4) = 9 bytes + $coordsBin = substr($wkb, 9, 16); + if (strlen($coordsBin) !== 16) { + throw new DatabaseException('Invalid WKB: missing coordinate bytes'); + } + + // Unpack two doubles + $coords = unpack('d2', $coordsBin); + if ($coords === false || !isset($coords[1], $coords[2])) { + throw new DatabaseException('Invalid WKB: failed to unpack coordinates'); + } + + return [(float)$coords[1], (float)$coords[2]]; + } + + public function decodeLinestring(string $wkb): array + { + if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); + + $points = explode(',', $inside); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + } + + // Skip 1 byte (endianness) + 4 bytes (type) + 4 bytes (SRID) + $offset = 9; + + // Number of points (4 bytes little-endian) + $numPointsArr = unpack('V', substr($wkb, $offset, 4)); + if ($numPointsArr === false || !isset($numPointsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + } + + $numPoints = $numPointsArr[1]; + $offset += 4; + + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $xArr = unpack('d', substr($wkb, $offset, 8)); + $yArr = unpack('d', substr($wkb, $offset + 8, 8)); + + if ($xArr === false || !isset($xArr[1]) || $yArr === false || !isset($yArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack point coordinates'); + } + + $points[] = [(float)$xArr[1], (float)$yArr[1]]; + $offset += 16; + } + + return $points; + } + + public function decodePolygon(string $wkb): array + { + // POLYGON((x1,y1),(x2,y2)) + if (str_starts_with($wkb, 'POLYGON((')) { + $start = strpos($wkb, '((') + 2; + $end = strrpos($wkb, '))'); + $inside = substr($wkb, $start, $end - $start); + + $rings = explode('),(', $inside); + return array_map(function ($ring) { + $points = explode(',', $ring); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + }, $rings); + } + + // Convert HEX string to binary if needed + if (str_starts_with($wkb, '0x') || ctype_xdigit($wkb)) { + $wkb = hex2bin(str_starts_with($wkb, '0x') ? substr($wkb, 2) : $wkb); + if ($wkb === false) { + throw new DatabaseException('Invalid hex WKB'); + } + } + + if (strlen($wkb) < 21) { + throw new DatabaseException('WKB too short to be a POLYGON'); + } + + // MySQL SRID-aware WKB layout: 4 bytes SRID prefix + $offset = 4; + + $byteOrder = ord($wkb[$offset]); + if ($byteOrder !== 1) { + throw new DatabaseException('Only little-endian WKB supported'); + } + $offset += 1; + + $typeArr = unpack('V', substr($wkb, $offset, 4)); + if ($typeArr === false || !isset($typeArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack geometry type'); + } + + $type = $typeArr[1]; + $hasSRID = ($type & 0x20000000) === 0x20000000; + $geomType = $type & 0xFF; + $offset += 4; + + if ($geomType !== 3) { // 3 = POLYGON + throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + } + + // Skip SRID in type flag if present + if ($hasSRID) { + $offset += 4; + } + + $numRingsArr = unpack('V', substr($wkb, $offset, 4)); + + if ($numRingsArr === false || !isset($numRingsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of rings'); + } + + $numRings = $numRingsArr[1]; + $offset += 4; + + $rings = []; + + for ($r = 0; $r < $numRings; $r++) { + $numPointsArr = unpack('V', substr($wkb, $offset, 4)); + + if ($numPointsArr === false || !isset($numPointsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + } + + $numPoints = $numPointsArr[1]; + $offset += 4; + $ring = []; + + for ($p = 0; $p < $numPoints; $p++) { + $xArr = unpack('d', substr($wkb, $offset, 8)); + if ($xArr === false) { + throw new DatabaseException('Failed to unpack X coordinate from WKB.'); + } + + $x = (float) $xArr[1]; + + $yArr = unpack('d', substr($wkb, $offset + 8, 8)); + if ($yArr === false) { + throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); + } + + $y = (float) $yArr[1]; + + $ring[] = [$x, $y]; + $offset += 16; + } + + $rings[] = $ring; + } + + return $rings; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index bcfa99509..0bee028ae 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -497,15 +497,16 @@ function (mixed $value) { }, /** * @param string|null $value - * @return string|null + * @return array|null */ function (?string $value) { - if (!is_string($value)) { - return $value; + if ($value === null) { + return null; } - return self::decodeSpatialData($value); + return $this->adapter->decodePoint($value); } ); + self::addFilter( Database::VAR_LINESTRING, /** @@ -524,15 +525,16 @@ function (mixed $value) { }, /** * @param string|null $value - * @return string|null + * @return array|null */ function (?string $value) { if (is_null($value)) { - return $value; + return null; } - return self::decodeSpatialData($value); + return $this->adapter->decodeLinestring($value); } ); + self::addFilter( Database::VAR_POLYGON, /** @@ -551,13 +553,13 @@ function (mixed $value) { }, /** * @param string|null $value - * @return string|null + * @return array|null */ function (?string $value) { if (is_null($value)) { - return $value; + return null; } - return self::decodeSpatialData($value); + return $this->adapter->decodePolygon($value); } ); } @@ -7361,57 +7363,4 @@ protected function encodeSpatialData(mixed $value, string $type): string throw new DatabaseException('Unknown spatial type: ' . $type); } } - - /** - * Decode spatial data from WKT (Well-Known Text) format to array format - * - * @param string $wkt - * @return array - * @throws DatabaseException - */ - public function decodeSpatialData(string $wkt): array - { - $upper = strtoupper($wkt); - - // POINT(x y) - if (str_starts_with($upper, 'POINT(')) { - $start = strpos($wkt, '(') + 1; - $end = strrpos($wkt, ')'); - $inside = substr($wkt, $start, $end - $start); - - $coords = explode(' ', trim($inside)); - return [(float)$coords[0], (float)$coords[1]]; - } - - // LINESTRING(x1 y1, x2 y2, ...) - if (str_starts_with($upper, 'LINESTRING(')) { - $start = strpos($wkt, '(') + 1; - $end = strrpos($wkt, ')'); - $inside = substr($wkt, $start, $end - $start); - - $points = explode(',', $inside); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); - } - - // POLYGON((x1,y1),(x2,y2)) - if (str_starts_with($upper, 'POLYGON((')) { - $start = strpos($wkt, '((') + 2; - $end = strrpos($wkt, '))'); - $inside = substr($wkt, $start, $end - $start); - - $rings = explode('),(', $inside); - return array_map(function ($ring) { - $points = explode(',', $ring); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); - }, $rings); - } - - return [$wkt]; - } } diff --git a/src/Database/PDO.php b/src/Database/PDO.php index 069ef88f8..245b0dfad 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use InvalidArgumentException; +use Utopia\CLI\Console; /** * A PDO wrapper that forwards method calls to the internal PDO instance. @@ -41,7 +42,27 @@ public function __construct( */ public function __call(string $method, array $args): mixed { - return $this->pdo->{$method}(...$args); + try { + return $this->pdo->{$method}(...$args); + } catch (\Throwable $e) { + if (Connection::hasError($e)) { + Console::warning('[Database] ' . $e->getMessage()); + Console::warning('[Database] Lost connection detected. Reconnecting...'); + + $inTransaction = $this->pdo->inTransaction(); + + // Attempt to reconnect + $this->reconnect(); + + // If we weren't in a transaction, also retry the query + // In a transaction we can't retry as the state is attached to the previous connection + if (!$inTransaction) { + return $this->pdo->{$method}(...$args); + } + } + + throw $e; + } } /** diff --git a/src/Database/Validator/Key.php b/src/Database/Validator/Key.php index 920ff7b92..1d180c38c 100644 --- a/src/Database/Validator/Key.php +++ b/src/Database/Validator/Key.php @@ -8,10 +8,15 @@ class Key extends Validator { protected bool $allowInternal = false; // If true, you keys starting with $ are allowed + /** + * Maximum length for Key validation + */ + protected const KEY_MAX_LENGTH = 255; + /** * @var string */ - protected string $message = 'Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char'; + protected string $message = 'Parameter must contain at most ' . self::KEY_MAX_LENGTH . ' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char'; /** * Get Description. @@ -76,8 +81,8 @@ public function isValid($value): bool if (\preg_match('/[^A-Za-z0-9\_\-\.]/', $value)) { return false; } - // At most 36 chars - if (\mb_strlen($value) > 36) { + // At most KEY_MAX_LENGTH chars + if (\mb_strlen($value) > self::KEY_MAX_LENGTH) { return false; } diff --git a/src/Database/Validator/Label.php b/src/Database/Validator/Label.php index 6cc4f031f..6c6cb8f4a 100644 --- a/src/Database/Validator/Label.php +++ b/src/Database/Validator/Label.php @@ -4,7 +4,7 @@ class Label extends Key { - protected string $message = 'Value must be a valid string between 1 and 36 chars containing only alphanumeric chars'; + protected string $message = 'Value must be a valid string between 1 and 255 chars containing only alphanumeric chars'; /** * Is valid. diff --git a/src/Database/Validator/UID.php b/src/Database/Validator/UID.php index 34d466e34..45971da66 100644 --- a/src/Database/Validator/UID.php +++ b/src/Database/Validator/UID.php @@ -13,6 +13,6 @@ class UID extends Key */ public function getDescription(): string { - return 'UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore'; + return 'UID must contain at most ' . self::KEY_MAX_LENGTH . ' chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore'; } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 24e3b173a..03461b12f 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -22,6 +22,34 @@ trait DocumentTests { + public function testBigintSequence(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->createCollection(__FUNCTION__); + + $sequence = 5_000_000_000_000_000; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { + $sequence = '01995753-881b-78cf-9506-2cffecf8f227'; + } + + $document = $database->createDocument(__FUNCTION__, new Document([ + '$sequence' => (string)$sequence, + '$permissions' => [ + Permission::read(Role::any()), + ], + ])); + + $this->assertEquals((string)$sequence, $document->getSequence()); + + $document = $database->getDocument(__FUNCTION__, $document->getId()); + $this->assertEquals((string)$sequence, $document->getSequence()); + + $document = $database->findOne(__FUNCTION__, [Query::equal('$sequence', [(string)$sequence])]); + $this->assertEquals((string)$sequence, $document->getSequence()); + } + public function testCreateDocument(): Document { /** @var Database $database */ @@ -3310,253 +3338,253 @@ public function testFindNotContains(): void } } - // public function testFindNotSearch(): void - // { - // /** @var Database $database */ - // $database = static::getDatabase(); - - // // Only test if fulltext search is supported - // if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { - // // Ensure fulltext index exists (may already exist from previous tests) - // try { - // $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); - // } catch (Throwable $e) { - // // Index may already exist, ignore duplicate error - // if (!str_contains($e->getMessage(), 'already exists')) { - // throw $e; - // } - // } - - // // Test notSearch - should return documents that don't match the search term - // $documents = $database->find('movies', [ - // Query::notSearch('name', 'captain'), - // ]); - - // $this->assertEquals(4, count($documents)); // All movies except the 2 with 'captain' in name - - // // Test notSearch with term that doesn't exist - should return all documents - // $documents = $database->find('movies', [ - // Query::notSearch('name', 'nonexistent'), - // ]); - - // $this->assertEquals(6, count($documents)); - - // // Test notSearch with partial term - // if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { - // $documents = $database->find('movies', [ - // Query::notSearch('name', 'cap'), - // ]); - - // $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' - // } - - // // Test notSearch with empty string - should return all documents - // $documents = $database->find('movies', [ - // Query::notSearch('name', ''), - // ]); - // $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing - - // // Test notSearch combined with other filters - // $documents = $database->find('movies', [ - // Query::notSearch('name', 'captain'), - // Query::lessThan('year', 2010) - // ]); - // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 - - // // Test notSearch with special characters - // $documents = $database->find('movies', [ - // Query::notSearch('name', '@#$%'), - // ]); - // $this->assertEquals(6, count($documents)); // All movies since special chars don't match - // } - - // $this->assertEquals(true, true); // Test must do an assertion - // } - - // public function testFindNotStartsWith(): void - // { - // /** @var Database $database */ - // $database = static::getDatabase(); - - // // Test notStartsWith - should return documents that don't start with 'Work' - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', 'Work'), - // ]); - - // $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' - - // // Test notStartsWith with non-existent prefix - should return all documents - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', 'NonExistent'), - // ]); - - // $this->assertEquals(6, count($documents)); - - // // Test notStartsWith with wildcard characters (should treat them literally) - // if ($this->getDatabase()->getAdapter() instanceof SQL) { - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', '%ork'), - // ]); - // } else { - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', '.*ork'), - // ]); - // } - - // $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns - - // // Test notStartsWith with empty string - should return no documents (all strings start with empty) - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', ''), - // ]); - // $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string - - // // Test notStartsWith with single character - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', 'C'), - // ]); - // $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' - - // // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', 'work'), // lowercase vs 'Work' - // ]); - // $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively - - // // Test notStartsWith combined with other queries - // $documents = $database->find('movies', [ - // Query::notStartsWith('name', 'Work'), - // Query::equal('year', [2006]) - // ]); - // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 - // } - - // public function testFindNotEndsWith(): void - // { - // /** @var Database $database */ - // $database = static::getDatabase(); - - // // Test notEndsWith - should return documents that don't end with 'Marvel' - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'Marvel'), - // ]); - - // $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' - - // // Test notEndsWith with non-existent suffix - should return all documents - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'NonExistent'), - // ]); - - // $this->assertEquals(6, count($documents)); - - // // Test notEndsWith with partial suffix - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'vel'), - // ]); - - // $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') - - // // Test notEndsWith with empty string - should return no documents (all strings end with empty) - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', ''), - // ]); - // $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string - - // // Test notEndsWith with single character - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'l'), - // ]); - // $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' - - // // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' - // ]); - // $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively - - // // Test notEndsWith combined with limit - // $documents = $database->find('movies', [ - // Query::notEndsWith('name', 'Marvel'), - // Query::limit(3) - // ]); - // $this->assertEquals(3, count($documents)); // Limited to 3 results - // $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies - // } - - // public function testFindNotBetween(): void - // { - // /** @var Database $database */ - // $database = static::getDatabase(); - // - // // Test notBetween with price range - should return documents outside the range - // $documents = $database->find('movies', [ - // Query::notBetween('price', 25.94, 25.99), - // ]); - // $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range - // - // // Test notBetween with range that includes no documents - should return all documents - // $documents = $database->find('movies', [ - // Query::notBetween('price', 30, 35), - // ]); - // $this->assertEquals(6, count($documents)); - // - // // Test notBetween with date range - // $documents = $database->find('movies', [ - // Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), - // ]); - // $this->assertEquals(0, count($documents)); // No movies outside this wide date range - // - // // Test notBetween with narrower date range - // $documents = $database->find('movies', [ - // Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), - // ]); - // $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - // - // // Test notBetween with updated date range - // $documents = $database->find('movies', [ - // Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), - // ]); - // $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - // - // // Test notBetween with year range (integer values) - // $documents = $database->find('movies', [ - // Query::notBetween('year', 2005, 2007), - // ]); - // $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range - // - // // Test notBetween with reversed range (start > end) - should still work - // $documents = $database->find('movies', [ - // Query::notBetween('price', 25.99, 25.94), // Note: reversed order - // ]); - // $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully - // - // // Test notBetween with same start and end values - // $documents = $database->find('movies', [ - // Query::notBetween('year', 2006, 2006), - // ]); - // $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 - // - // // Test notBetween combined with other filters - // $documents = $database->find('movies', [ - // Query::notBetween('price', 25.94, 25.99), - // Query::orderDesc('year'), - // Query::limit(2) - // ]); - // $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range - // - // // Test notBetween with extreme ranges - // $documents = $database->find('movies', [ - // Query::notBetween('year', -1000, 1000), // Very wide range - // ]); - // $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range - // - // // Test notBetween with float precision - // $documents = $database->find('movies', [ - // Query::notBetween('price', 25.945, 25.955), // Very narrow range - // ]); - // $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range - // } + public function testFindNotSearch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only test if fulltext search is supported + if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { + // Ensure fulltext index exists (may already exist from previous tests) + try { + $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); + } catch (Throwable $e) { + // Index may already exist, ignore duplicate error + if (!str_contains($e->getMessage(), 'already exists')) { + throw $e; + } + } + + // Test notSearch - should return documents that don't match the search term + $documents = $database->find('movies', [ + Query::notSearch('name', 'captain'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 with 'captain' in name + + // Test notSearch with term that doesn't exist - should return all documents + $documents = $database->find('movies', [ + Query::notSearch('name', 'nonexistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notSearch with partial term + if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { + $documents = $database->find('movies', [ + Query::notSearch('name', 'cap'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' + } + + // Test notSearch with empty string - should return all documents + $documents = $database->find('movies', [ + Query::notSearch('name', ''), + ]); + $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing + + // Test notSearch combined with other filters + $documents = $database->find('movies', [ + Query::notSearch('name', 'captain'), + Query::lessThan('year', 2010) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 + + // Test notSearch with special characters + $documents = $database->find('movies', [ + Query::notSearch('name', '@#$%'), + ]); + $this->assertEquals(6, count($documents)); // All movies since special chars don't match + } + + $this->assertEquals(true, true); // Test must do an assertion + } + + public function testFindNotStartsWith(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notStartsWith - should return documents that don't start with 'Work' + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'Work'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' + + // Test notStartsWith with non-existent prefix - should return all documents + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'NonExistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notStartsWith with wildcard characters (should treat them literally) + if ($this->getDatabase()->getAdapter() instanceof SQL) { + $documents = $database->find('movies', [ + Query::notStartsWith('name', '%ork'), + ]); + } else { + $documents = $database->find('movies', [ + Query::notStartsWith('name', '.*ork'), + ]); + } + + $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns + + // Test notStartsWith with empty string - should return no documents (all strings start with empty) + $documents = $database->find('movies', [ + Query::notStartsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + + // Test notStartsWith with single character + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'C'), + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' + + // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'work'), // lowercase vs 'Work' + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively + + // Test notStartsWith combined with other queries + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'Work'), + Query::equal('year', [2006]) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 + } + + public function testFindNotEndsWith(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notEndsWith - should return documents that don't end with 'Marvel' + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'Marvel'), + ]); + + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' + + // Test notEndsWith with non-existent suffix - should return all documents + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'NonExistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notEndsWith with partial suffix + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'vel'), + ]); + + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + + // Test notEndsWith with empty string - should return no documents (all strings end with empty) + $documents = $database->find('movies', [ + Query::notEndsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string + + // Test notEndsWith with single character + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'l'), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + + // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively + + // Test notEndsWith combined with limit + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'Marvel'), + Query::limit(3) + ]); + $this->assertEquals(3, count($documents)); // Limited to 3 results + $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies + } + + public function testFindNotBetween(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notBetween with price range - should return documents outside the range + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + ]); + $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range + + // Test notBetween with range that includes no documents - should return all documents + $documents = $database->find('movies', [ + Query::notBetween('price', 30, 35), + ]); + $this->assertEquals(6, count($documents)); + + // Test notBetween with date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), + ]); + $this->assertEquals(0, count($documents)); // No movies outside this wide date range + + // Test notBetween with narrower date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + + // Test notBetween with updated date range + $documents = $database->find('movies', [ + Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + + // Test notBetween with year range (integer values) + $documents = $database->find('movies', [ + Query::notBetween('year', 2005, 2007), + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range + + // Test notBetween with reversed range (start > end) - should still work + $documents = $database->find('movies', [ + Query::notBetween('price', 25.99, 25.94), // Note: reversed order + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully + + // Test notBetween with same start and end values + $documents = $database->find('movies', [ + Query::notBetween('year', 2006, 2006), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 + + // Test notBetween combined with other filters + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + Query::orderDesc('year'), + Query::limit(2) + ]); + $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range + + // Test notBetween with extreme ranges + $documents = $database->find('movies', [ + Query::notBetween('year', -1000, 1000), // Very wide range + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range + + // Test notBetween with float precision + $documents = $database->find('movies', [ + Query::notBetween('price', 25.945, 25.955), // Very narrow range + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range + } public function testFindSelect(): void { diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 92ebd2c7e..e9839b7de 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -113,25 +113,29 @@ public function testSpatialTypeDocuments(): void $this->assertEquals(true, $database->createIndex($collectionName, 'line_spatial', Database::INDEX_SPATIAL, ['lineAttr'])); $this->assertEquals(true, $database->createIndex($collectionName, 'poly_spatial', Database::INDEX_SPATIAL, ['polyAttr'])); + $point = [5.0, 5.0]; + $linestring = [[1.0, 2.0], [3.0, 4.0]]; + $polygon = [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [10.0, 0.0], [0.0, 0.0]]]; + // Create test document $doc1 = new Document([ '$id' => 'doc1', - 'pointAttr' => [5.0, 5.0], - 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], - 'polyAttr' => [ - [ - [0.0, 0.0], - [0.0, 10.0], - [10.0, 10.0], - [10.0, 0.0], - [0.0, 0.0] - ] - ], + 'pointAttr' => $point, + 'lineAttr' => $linestring, + 'polyAttr' => $polygon, '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] ]); $createdDoc = $database->createDocument($collectionName, $doc1); $this->assertInstanceOf(Document::class, $createdDoc); - $this->assertEquals([5.0, 5.0], $createdDoc->getAttribute('pointAttr')); + $this->assertEquals($point, $createdDoc->getAttribute('pointAttr')); + $this->assertEquals($linestring, $createdDoc->getAttribute('lineAttr')); + $this->assertEquals($polygon, $createdDoc->getAttribute('polyAttr')); + + $createdDoc = $database->getDocument($collectionName, 'doc1'); + $this->assertInstanceOf(Document::class, $createdDoc); + $this->assertEquals($point, $createdDoc->getAttribute('pointAttr')); + $this->assertEquals($linestring, $createdDoc->getAttribute('lineAttr')); + $this->assertEquals($polygon, $createdDoc->getAttribute('polyAttr')); // Update spatial data $doc1->setAttribute('pointAttr', [6.0, 6.0]); diff --git a/tests/unit/PDOTest.php b/tests/unit/PDOTest.php index a17de1eb3..45e9a12a2 100644 --- a/tests/unit/PDOTest.php +++ b/tests/unit/PDOTest.php @@ -41,44 +41,44 @@ public function testMethodCallIsForwardedToPDO(): void $this->assertSame($pdoStatementMock, $result); } - // public function testLostConnectionRetriesCall(): void - // { - // $dsn = 'sqlite::memory:'; - // $pdoWrapper = $this->getMockBuilder(PDO::class) - // ->setConstructorArgs([$dsn, null, null, []]) - // ->onlyMethods(['reconnect']) - // ->getMock(); - // - // $pdoMock = $this->getMockBuilder(\PDO::class) - // ->disableOriginalConstructor() - // ->getMock(); - // $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) - // ->disableOriginalConstructor() - // ->getMock(); - // - // $pdoMock->expects($this->exactly(2)) - // ->method('query') - // ->with('SELECT 1') - // ->will($this->onConsecutiveCalls( - // $this->throwException(new \Exception("Lost connection")), - // $pdoStatementMock - // )); - // - // $reflection = new ReflectionClass($pdoWrapper); - // $pdoProperty = $reflection->getProperty('pdo'); - // $pdoProperty->setAccessible(true); - // $pdoProperty->setValue($pdoWrapper, $pdoMock); - // - // $pdoWrapper->expects($this->once()) - // ->method('reconnect') - // ->willReturnCallback(function () use ($pdoWrapper, $pdoMock, $pdoProperty) { - // $pdoProperty->setValue($pdoWrapper, $pdoMock); - // }); - // - // $result = $pdoWrapper->query('SELECT 1'); - // - // $this->assertSame($pdoStatementMock, $result); - // } + public function testLostConnectionRetriesCall(): void + { + $dsn = 'sqlite::memory:'; + $pdoWrapper = $this->getMockBuilder(PDO::class) + ->setConstructorArgs([$dsn, null, null, []]) + ->onlyMethods(['reconnect']) + ->getMock(); + + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + + $pdoMock->expects($this->exactly(2)) + ->method('query') + ->with('SELECT 1') + ->will($this->onConsecutiveCalls( + $this->throwException(new \Exception("Lost connection")), + $pdoStatementMock + )); + + $reflection = new ReflectionClass($pdoWrapper); + $pdoProperty = $reflection->getProperty('pdo'); + $pdoProperty->setAccessible(true); + $pdoProperty->setValue($pdoWrapper, $pdoMock); + + $pdoWrapper->expects($this->once()) + ->method('reconnect') + ->willReturnCallback(function () use ($pdoWrapper, $pdoMock, $pdoProperty) { + $pdoProperty->setValue($pdoWrapper, $pdoMock); + }); + + $result = $pdoWrapper->query('SELECT 1'); + + $this->assertSame($pdoStatementMock, $result); + } public function testNonLostConnectionExceptionIsRethrown(): void { diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index c48755cb2..188d1873c 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -86,7 +86,6 @@ public function testCreate(): void $this->assertEquals('title', $query->getAttribute()); $this->assertEquals([], $query->getValues()); - // Test new NOT query types $query = Query::notContains('tags', ['test', 'example']); $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); @@ -206,7 +205,6 @@ public function testParse(): void $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(8.5, $query->getValues()[0]); - // Test new NOT query types parsing $query = Query::parse(Query::notContains('tags', ['unwanted', 'spam'])->toString()); $this->assertEquals('notContains', $query->getMethod()); $this->assertEquals('tags', $query->getAttribute()); @@ -431,7 +429,6 @@ public function testIsMethod(): void public function testNewQueryTypesInTypesArray(): void { - // Test that all new query types are included in the TYPES array $this->assertContains(Query::TYPE_NOT_CONTAINS, Query::TYPES); $this->assertContains(Query::TYPE_NOT_SEARCH, Query::TYPES); $this->assertContains(Query::TYPE_NOT_STARTS_WITH, Query::TYPES); diff --git a/tests/unit/Validator/KeyTest.php b/tests/unit/Validator/KeyTest.php index ca85ae56b..e09ef402e 100644 --- a/tests/unit/Validator/KeyTest.php +++ b/tests/unit/Validator/KeyTest.php @@ -66,11 +66,9 @@ public function testValues(): void $this->assertEquals(false, $this->object->isValid('as+5dasdasdas')); $this->assertEquals(false, $this->object->isValid('as=5dasdasdas')); - // At most 36 chars - $this->assertEquals(true, $this->object->isValid('socialAccountForYoutubeSubscribersss')); - $this->assertEquals(false, $this->object->isValid('socialAccountForYoutubeSubscriberssss')); - $this->assertEquals(true, $this->object->isValid('5f058a89258075f058a89258075f058t9214')); - $this->assertEquals(false, $this->object->isValid('5f058a89258075f058a89258075f058tx9214')); + // At most 255 chars + $this->assertEquals(true, $this->object->isValid(str_repeat('a', 255))); + $this->assertEquals(false, $this->object->isValid(str_repeat('a', 256))); // Internal keys $validator = new Key(true); diff --git a/tests/unit/Validator/LabelTest.php b/tests/unit/Validator/LabelTest.php index c3eef2fb4..3d9bf7576 100644 --- a/tests/unit/Validator/LabelTest.php +++ b/tests/unit/Validator/LabelTest.php @@ -58,10 +58,8 @@ public function testValues(): void $this->assertEquals(false, $this->object->isValid('as+5dasdasdas')); $this->assertEquals(false, $this->object->isValid('as=5dasdasdas')); - // At most 36 chars - $this->assertEquals(true, $this->object->isValid('socialAccountForYoutubeSubscribersss')); - $this->assertEquals(false, $this->object->isValid('socialAccountForYoutubeSubscriberssss')); - $this->assertEquals(true, $this->object->isValid('5f058a89258075f058a89258075f058t9214')); - $this->assertEquals(false, $this->object->isValid('5f058a89258075f058a89258075f058tx9214')); + // At most 255 chars + $this->assertEquals(true, $this->object->isValid(str_repeat('a', 255))); + $this->assertEquals(false, $this->object->isValid(str_repeat('a', 256))); } } diff --git a/tests/unit/Validator/PermissionsTest.php b/tests/unit/Validator/PermissionsTest.php index bc03fb201..505e69dec 100644 --- a/tests/unit/Validator/PermissionsTest.php +++ b/tests/unit/Validator/PermissionsTest.php @@ -248,24 +248,25 @@ public function testInvalidPermissions(): void // team:$value, member:$value and user:$value must have valid Key for $value // No leading special chars $this->assertFalse($object->isValid([Permission::read(Role::user('_1234'))])); - $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::team('-1234'))])); - $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::member('.1234'))])); - $this->assertEquals('Role "member" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "member" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); // No unsupported special characters $this->assertFalse($object->isValid([Permission::read(Role::user('12$4'))])); - $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::user('12&4'))])); - $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::user('ab(124'))])); - $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); - // Shorter than 36 chars - $this->assertTrue($object->isValid([Permission::read(Role::user(ID::custom('aaaaaaaabbbbbbbbccccccccddddddddeeee')))])); - $this->assertFalse($object->isValid([Permission::read(Role::user(ID::custom('aaaaaaaabbbbbbbbccccccccddddddddeeeee')))])); - $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + // Shorter than 255 chars + + $this->assertTrue($object->isValid([Permission::read(Role::user(ID::custom(str_repeat('a', 255))))])); + $this->assertFalse($object->isValid([Permission::read(Role::user(ID::custom(str_repeat('a', 256))))])); + $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); // Permission role must begin with one of: member, role, team, user $this->assertFalse($object->isValid(['update("memmber:1234")'])); @@ -277,7 +278,7 @@ public function testInvalidPermissions(): void // Team permission $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('_abcd')))])); - $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd/')))])); $this->assertEquals('Dimension must not be empty', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom(''), 'abcd'))])); @@ -287,9 +288,9 @@ public function testInvalidPermissions(): void $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), 'e/fgh'))])); $this->assertEquals('Only one dimension can be provided', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('ab&cd3'), 'efgh'))])); - $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), 'ef*gh'))])); - $this->assertEquals('Role "team" dimension value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); + $this->assertEquals('Role "team" dimension value is invalid: Parameter must contain at most 255 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); // Permission-list length must be valid $object = new Permissions(100);