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 82a5c7432..39ccc5583 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1301,7 +1301,7 @@ public function getSequences(string $collection, array $documents): array // Continue fetching with getMore while ($cursorId && $cursorId !== 0) { - $moreResponse = $this->client->getMore($cursorId, $name, self::DEFAULT_BATCH_SIZE); + $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); $moreResults = $moreResponse->cursor->nextBatch ?? []; if (empty($moreResults)) { @@ -1313,7 +1313,7 @@ public function getSequences(string $collection, array $documents): array } // Update cursor ID for next iteration - $cursorId = $moreResponse->cursor->id ?? null; + $cursorId = (int)($moreResponse->cursor->id ?? 0); } } catch (MongoException $e) { throw $this->processException($e); @@ -1612,7 +1612,6 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $response = $this->client->find($name, $filters, $options); $results = $response->cursor->firstBatch ?? []; - // Process first batch foreach ($results as $result) { $record = $this->replaceChars('_', '$', (array)$result); @@ -1629,7 +1628,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 break; } - $moreResponse = $this->client->getMore($cursorId, $name, self::DEFAULT_BATCH_SIZE); + $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); $moreResults = $moreResponse->cursor->nextBatch ?? []; if (empty($moreResults)) { @@ -1646,7 +1645,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) { @@ -2264,7 +2263,7 @@ public function getSupportForFulltextWildcardIndex(): bool */ public function getSupportForQueryContains(): bool { - return false; + return true; } /** @@ -2553,7 +2552,6 @@ public function getKeywords(): array protected function processException(Exception $e): \Exception { - // Timeout if ($e->getCode() === 50) { return new Timeout('Query timed out', $e->getCode(), $e); @@ -2660,4 +2658,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..2e4aa4b29 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 */ @@ -3248,315 +3276,315 @@ public function testFindEndsWith(): void $this->assertEquals(1, count($documents)); } - public function testFindNotContains(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForQueryContains()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Test notContains with array attributes - should return documents that don't contain specified genres - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']) - ]); - - $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre - - // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics', 'kids']), - ]); - - $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' - - // Test notContains with non-existent genre - should return all documents - $documents = $database->find('movies', [ - Query::notContains('genres', ['non-existent']), - ]); - - $this->assertEquals(6, count($documents)); - - // Test notContains with string attribute (substring search) - $documents = $database->find('movies', [ - Query::notContains('name', ['Captain']) - ]); - $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' - - // Test notContains combined with other queries (AND logic) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']), - Query::greaterThan('year', 2000) - ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 - - // Test notContains with case sensitivity - $documents = $database->find('movies', [ - Query::notContains('genres', ['COMICS']) // Different case - ]); - $this->assertEquals(6, count($documents)); // All movies since case doesn't match - - // Test error handling for invalid attribute type - try { - $database->find('movies', [ - Query::notContains('price', [10.5]), - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array or string.', $e->getMessage()); - $this->assertTrue($e instanceof DatabaseException); - } - } - - // 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; + // public function testFindNotContains(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); + // + // if (!$database->getAdapter()->getSupportForQueryContains()) { + // $this->expectNotToPerformAssertions(); + // return; + // } + // + // // Test notContains with array attributes - should return documents that don't contain specified genres + // $documents = $database->find('movies', [ + // Query::notContains('genres', ['comics']) + // ]); + // + // $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre + // + // // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) + // $documents = $database->find('movies', [ + // Query::notContains('genres', ['comics', 'kids']), + // ]); + // + // $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' + // + // // Test notContains with non-existent genre - should return all documents + // $documents = $database->find('movies', [ + // Query::notContains('genres', ['non-existent']), + // ]); + // + // $this->assertEquals(6, count($documents)); + // + // // Test notContains with string attribute (substring search) + // $documents = $database->find('movies', [ + // Query::notContains('name', ['Captain']) + // ]); + // $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' + // + // // Test notContains combined with other queries (AND logic) + // $documents = $database->find('movies', [ + // Query::notContains('genres', ['comics']), + // Query::greaterThan('year', 2000) + // ]); + // $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 + // + // // Test notContains with case sensitivity + // $documents = $database->find('movies', [ + // Query::notContains('genres', ['COMICS']) // Different case + // ]); + // $this->assertEquals(6, count($documents)); // All movies since case doesn't match + // + // // Test error handling for invalid attribute type + // try { + // $database->find('movies', [ + // Query::notContains('price', [10.5]), + // ]); + // $this->fail('Failed to throw exception'); + // } catch (Throwable $e) { + // $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array or string.', $e->getMessage()); + // $this->assertTrue($e instanceof DatabaseException); + // } + // } + // + // 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 // } - - // // Test notSearch - should return documents that don't match the search term + // + // $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::notSearch('name', 'captain'), + // Query::notStartsWith('name', 'Work'), // ]); - - // $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 + // + // $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::notSearch('name', 'nonexistent'), + // Query::notStartsWith('name', 'NonExistent'), // ]); - + // // $this->assertEquals(6, count($documents)); - - // // Test notSearch with partial term - // if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { + // + // // Test notStartsWith with wildcard characters (should treat them literally) + // if ($this->getDatabase()->getAdapter() instanceof SQL) { // $documents = $database->find('movies', [ - // Query::notSearch('name', 'cap'), + // Query::notStartsWith('name', '%ork'), + // ]); + // } else { + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', '.*ork'), // ]); - - // $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' // } - - // // Test notSearch with empty string - should return all documents + // + // $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::notSearch('name', ''), + // Query::notStartsWith('name', ''), // ]); - // $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing - - // // Test notSearch combined with other filters + // $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + // + // // Test notStartsWith with single character // $documents = $database->find('movies', [ - // Query::notSearch('name', 'captain'), - // Query::lessThan('year', 2010) + // Query::notStartsWith('name', 'C'), // ]); - // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 - - // // Test notSearch with special characters + // $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::notSearch('name', '@#$%'), + // Query::notStartsWith('name', 'Work'), + // Query::equal('year', [2006]) // ]); - // $this->assertEquals(6, count($documents)); // All movies since special chars don't match + // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 // } - - // $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) { + // + // 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::notStartsWith('name', '%ork'), + // Query::notEndsWith('name', ''), // ]); - // } else { + // $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::notStartsWith('name', '.*ork'), + // 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 // } - - // $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 + // public function testFindNotBetween(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); // - // // 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 - // } + // // 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/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);