From 9693054fec5f18269998052b8de52fc98def43b4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 26 Sep 2024 21:52:06 -0400 Subject: [PATCH] Add ORDER BY support - Update deps --- composer.lock | 70 +++++++++--------- src/DocumentList.php | 15 ++-- src/Field.php | 14 ++++ src/Find.php | 48 ++++++++----- src/Query.php | 42 +++++++++++ tests/integration/postgresql/FindTest.php | 87 ++++++++++++++++++++++- tests/integration/sqlite/FindTest.php | 49 ++++++++++++- tests/unit/FieldTest.php | 27 ++++--- tests/unit/QueryTest.php | 77 ++++++++++++++++++++ 9 files changed, 363 insertions(+), 66 deletions(-) diff --git a/composer.lock b/composer.lock index 93f2c4a..6331c2e 100644 --- a/composer.lock +++ b/composer.lock @@ -166,16 +166,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.2.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", "shasum": "" }, "require": { @@ -218,9 +218,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-09-15T16:40:33+00:00" }, { "name": "phar-io/manifest", @@ -342,16 +342,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.2", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0ca1c7bb55fca8fe6448f16fff0f311ccec960a1" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0ca1c7bb55fca8fe6448f16fff0f311ccec960a1", - "reference": "0ca1c7bb55fca8fe6448f16fff0f311ccec960a1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -396,7 +396,7 @@ "type": "github" } ], - "time": "2024-09-05T16:09:28+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -723,16 +723,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.3.3", + "version": "11.3.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "8ed08766d9a2ed979a2f5fdbb95a0671523419c1" + "reference": "d62c45a19c665bb872c2a47023a0baf41a98bb2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8ed08766d9a2ed979a2f5fdbb95a0671523419c1", - "reference": "8ed08766d9a2ed979a2f5fdbb95a0671523419c1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d62c45a19c665bb872c2a47023a0baf41a98bb2b", + "reference": "d62c45a19c665bb872c2a47023a0baf41a98bb2b", "shasum": "" }, "require": { @@ -753,13 +753,13 @@ "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.0.2", + "sebastian/comparator": "^6.1.0", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.0", "sebastian/exporter": "^6.1.3", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.0.1", + "sebastian/type": "^5.1.0", "sebastian/version": "^5.0.1" }, "suggest": { @@ -803,7 +803,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.3.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.3.6" }, "funding": [ { @@ -819,7 +819,7 @@ "type": "tidelift" } ], - "time": "2024-09-04T13:34:52+00:00" + "time": "2024-09-19T10:54:28+00:00" }, { "name": "sebastian/cli-parser", @@ -993,16 +993,16 @@ }, { "name": "sebastian/comparator", - "version": "6.0.2", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81" + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/450d8f237bd611c45b5acf0733ce43e6bb280f81", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa37b9e2ca618cb051d71b60120952ee8ca8b03d", + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d", "shasum": "" }, "require": { @@ -1013,12 +1013,12 @@ "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -1058,7 +1058,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.1.0" }, "funding": [ { @@ -1066,7 +1066,7 @@ "type": "github" } ], - "time": "2024-08-12T06:07:25+00:00" + "time": "2024-09-11T15:42:56+00:00" }, { "name": "sebastian/complexity", @@ -1635,28 +1635,28 @@ }, { "name": "sebastian/type", - "version": "5.0.1", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fb6a6566f9589e86661291d13eba708cce5eb4aa" + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb6a6566f9589e86661291d13eba708cce5eb4aa", - "reference": "fb6a6566f9589e86661291d13eba708cce5eb4aa", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -1680,7 +1680,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" }, "funding": [ { @@ -1688,7 +1688,7 @@ "type": "github" } ], - "time": "2024-07-03T05:11:49+00:00" + "time": "2024-09-17T13:12:04+00:00" }, { "name": "sebastian/version", diff --git a/src/DocumentList.php b/src/DocumentList.php index 9fe3ec8..d351e48 100644 --- a/src/DocumentList.php +++ b/src/DocumentList.php @@ -35,10 +35,12 @@ class DocumentList */ private function __construct(private ?PDOStatement &$result, private readonly Mapper $mapper) { - if ($row = $this->result->fetch(PDO::FETCH_ASSOC)) { - $this->first = $this->mapper->map($row); - } else { - $this->result = null; + if (!is_null($this->result)) { + if ($row = $this->result->fetch(PDO::FETCH_ASSOC)) { + $this->first = $this->mapper->map($row); + } else { + $this->result = null; + } } } @@ -67,6 +69,11 @@ class DocumentList $this->isConsumed = true; return; } + if (!$this->first) { + $this->isConsumed = true; + $this->result = null; + return; + } yield $this->first; while ($row = $this->result->fetch(PDO::FETCH_ASSOC)) { yield $this->mapper->map($row); diff --git a/src/Field.php b/src/Field.php index 005f056..cecaf40 100644 --- a/src/Field.php +++ b/src/Field.php @@ -387,4 +387,18 @@ class Field { return self::notExists($fieldName); } + + /** + * Create a named fields (used for creating fields for ORDER BY clauses) + * + * Prepend the field name with 'n:' to treat the field as a number; prepend the field name with 'i:' to perform + * a case-insensitive ordering. + * + * @param string $name The name of the field, plus any direction for the ordering + * @return self + */ + public static function named(string $name): self + { + return new self($name, Op::Equal, '', ''); + } } diff --git a/src/Find.php b/src/Find.php index a8157f8..257662d 100644 --- a/src/Find.php +++ b/src/Find.php @@ -22,12 +22,14 @@ class Find * @template TDoc The type of document to be retrieved * @param string $tableName The table from which documents should be retrieved * @param class-string $className The name of the class to be retrieved + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return DocumentList A list of all documents from the table * @throws DocumentException If any is encountered */ - public static function all(string $tableName, string $className): DocumentList + public static function all(string $tableName, string $className, array $orderBy = []): DocumentList { - return Custom::list(Query::selectFromTable($tableName), [], new DocumentMapper($className)); + return Custom::list(Query::selectFromTable($tableName) . Query::orderBy($orderBy), [], + new DocumentMapper($className)); } /** @@ -54,15 +56,16 @@ class Find * @param Field[] $fields The field comparison to match * @param class-string $className The name of the class to be retrieved * @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All) + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return DocumentList A list of documents matching the given field comparison * @throws DocumentException If any is encountered */ public static function byFields(string $tableName, array $fields, string $className, - ?FieldMatch $match = null): DocumentList + ?FieldMatch $match = null, array $orderBy = []): DocumentList { Parameters::nameFields($fields); - return Custom::list(Query\Find::byFields($tableName, $fields, $match), Parameters::addFields($fields, []), - new DocumentMapper($className)); + return Custom::list(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy), + Parameters::addFields($fields, []), new DocumentMapper($className)); } /** @@ -72,13 +75,15 @@ class Find * @param string $tableName The name of the table from which documents should be retrieved * @param mixed[]|object $criteria The criteria for the JSON containment query * @param class-string $className The name of the class to be retrieved + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return DocumentList A list of documents matching the JSON containment query * @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs */ - public static function byContains(string $tableName, array|object $criteria, string $className): DocumentList + public static function byContains(string $tableName, array|object $criteria, string $className, + array $orderBy = []): DocumentList { - return Custom::list(Query\Find::byContains($tableName), Parameters::json(':criteria', $criteria), - new DocumentMapper($className)); + return Custom::list(Query\Find::byContains($tableName) . Query::orderBy($orderBy), + Parameters::json(':criteria', $criteria), new DocumentMapper($className)); } /** @@ -88,12 +93,15 @@ class Find * @param string $tableName The name of the table from which documents should be retrieved * @param string $path The JSON Path match string * @param class-string $className The name of the class to be retrieved + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return DocumentList A list of documents matching the JSON Path * @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs */ - public static function byJsonPath(string $tableName, string $path, string $className): DocumentList + public static function byJsonPath(string $tableName, string $path, string $className, + array $orderBy = []): DocumentList { - return Custom::list(Query\Find::byJsonPath($tableName), [':path' => $path], new DocumentMapper($className)); + return Custom::list(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path], + new DocumentMapper($className)); } /** @@ -104,14 +112,15 @@ class Find * @param Field[] $fields The field comparison to match * @param class-string $className The name of the class to be retrieved * @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All) + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return Option A `Some` instance with the first document if any matches are found, `None` otherwise * @throws DocumentException If any is encountered */ public static function firstByFields(string $tableName, array $fields, string $className, - ?FieldMatch $match = null): Option + ?FieldMatch $match = null, array $orderBy = []): Option { Parameters::nameFields($fields); - return Custom::single(Query\Find::byFields($tableName, $fields, $match), + return Custom::single(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy), Parameters::addFields($fields, []), new DocumentMapper($className)); } @@ -122,13 +131,15 @@ class Find * @param string $tableName The name of the table from which documents should be retrieved * @param mixed[]|object $criteria The criteria for the JSON containment query * @param class-string $className The name of the class to be retrieved + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return Option A `Some` instance with the first document if any matches are found, `None` otherwise * @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs */ - public static function firstByContains(string $tableName, array|object $criteria, string $className): Option + public static function firstByContains(string $tableName, array|object $criteria, string $className, + array $orderBy = []): Option { - return Custom::single(Query\Find::byContains($tableName), Parameters::json(':criteria', $criteria), - new DocumentMapper($className)); + return Custom::single(Query\Find::byContains($tableName) . Query::orderBy($orderBy), + Parameters::json(':criteria', $criteria), new DocumentMapper($className)); } /** @@ -138,11 +149,14 @@ class Find * @param string $tableName The name of the table from which documents should be retrieved * @param string $path The JSON Path match string * @param class-string $className The name of the class to be retrieved + * @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering) * @return Option A `Some` instance with the first document if any matches are found, `None` otherwise * @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs */ - public static function firstByJsonPath(string $tableName, string $path, string $className): Option + public static function firstByJsonPath(string $tableName, string $path, string $className, + array $orderBy = []): Option { - return Custom::single(Query\Find::byJsonPath($tableName), [':path' => $path], new DocumentMapper($className)); + return Custom::single(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path], + new DocumentMapper($className)); } } diff --git a/src/Query.php b/src/Query.php index be01fc9..ebe6211 100644 --- a/src/Query.php +++ b/src/Query.php @@ -142,4 +142,46 @@ class Query { return "UPDATE $tableName SET data = :data WHERE " . self::whereById(); } + + /** + * Create an `ORDER BY` clause ('n:' treats field as number, 'i:' does case-insensitive ordering) + * + * @param Field[] $fields The fields, named for the field plus directions (ex. 'field DESC NULLS FIRST') + * @return string The ORDER BY clause with the given fields + * @throws Exception If the database mode has not been set + */ + public static function orderBy(array $fields): string + { + if (empty($fields)) return ""; + + $mode = Configuration::mode('render ORDER BY clause'); + $sqlFields = array_map(function (Field $it) use ($mode) { + if (str_contains($it->fieldName, ' ')) { + $parts = explode(' ', $it->fieldName); + $it->fieldName = array_shift($parts); + $direction = ' ' . implode(' ', $parts); + } else { + $direction = ''; + } + + if (str_starts_with($it->fieldName, 'n:')) { + $it->fieldName = substr($it->fieldName, 2); + $value = match ($mode) { + Mode::PgSQL => '(' . $it->path() . ')::numeric', + Mode::SQLite => $it->path() + }; + } elseif (str_starts_with($it->fieldName, 'i:')) { + $it->fieldName = substr($it->fieldName, 2); + $value = match ($mode) { + Mode::PgSQL => 'LOWER(' . $it->path() . ')', + Mode::SQLite => $it->path() . ' COLLATE NOCASE' + }; + } else { + $value = $it->path(); + } + + return $value . $direction; + }, $fields); + return ' ORDER BY ' . implode(', ', $sqlFields); + } } diff --git a/tests/integration/postgresql/FindTest.php b/tests/integration/postgresql/FindTest.php index 8a124e1..3fd2683 100644 --- a/tests/integration/postgresql/FindTest.php +++ b/tests/integration/postgresql/FindTest.php @@ -44,6 +44,34 @@ class FindTest extends TestCase $this->assertEquals(5, $count, 'There should have been 5 documents in the list'); } + #[TestDox('all() succeeds when ordering data ascending')] + public function testAllSucceedsWhenOrderingDataAscending(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['five', 'four', 'one', 'three', 'two'], $ids, 'The documents were not ordered correctly'); + } + + #[TestDox('all() succeeds when ordering data descending')] + public function testAllSucceedsWhenOrderingDataDescending(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id DESC')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['two', 'three', 'one', 'four', 'five'], $ids, 'The documents were not ordered correctly'); + } + + #[TestDox('all() succeeds when ordering data numerically')] + public function testAllSucceedsWhenOrderingDataNumerically(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, + [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['two', 'four', 'one', 'three', 'five'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('all() succeeds when there is no data')] public function testAllSucceedsWhenThereIsNoData(): void { @@ -89,8 +117,18 @@ class FindTest extends TestCase $this->assertEquals(1, $count, 'There should have been 1 document in the list'); } + #[TestDox('byFields() succeeds when documents are found and ordered')] + public function testByFieldsSucceedsWhenDocumentsAreFoundAndOrdered(): void + { + $docs = Find::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], TestDocument::class, + FieldMatch::All, [Field::named('id')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['five', 'four'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('byFields() succeeds when documents are found using IN with numeric field')] - public function testByFieldsSucceedsWhenDocumentsAreFoundUsingInWithNumericField() + public function testByFieldsSucceedsWhenDocumentsAreFoundUsingInWithNumericField(): void { $docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])], TestDocument::class); $this->assertNotNull($docs, 'There should have been a document list returned'); @@ -141,6 +179,16 @@ class FindTest extends TestCase $this->assertEquals(2, $count, 'There should have been 2 documents in the list'); } + #[TestDox('byContains() succeeds when documents are found and ordered')] + public function testByContainsSucceedsWhenDocumentsAreFoundAndOrdered(): void + { + $docs = Find::byContains(ThrowawayDb::TABLE, ['sub' => ['foo' => 'green']], TestDocument::class, + [Field::named('value')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['two', 'four'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('byContains() succeeds when no documents are found')] public function testByContainsSucceedsWhenNoDocumentsAreFound(): void { @@ -159,6 +207,16 @@ class FindTest extends TestCase $this->assertEquals(2, $count, 'There should have been 2 documents in the list'); } + #[TestDox('byJsonPath() succeeds when documents are found and ordered')] + public function testByJsonPathSucceedsWhenDocumentsAreFoundAndOrdered(): void + { + $docs = Find::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class, + [Field::named('id')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['five', 'four'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('byJsonPath() succeeds when no documents are found')] public function testByJsonPathSucceedsWhenNoDocumentsAreFound(): void { @@ -183,6 +241,15 @@ class FindTest extends TestCase $this->assertContains($doc->get()->id, ['two', 'four'], 'An incorrect document was returned'); } + #[TestDox('firstByFields() succeeds when multiple ordered documents are found')] + public function testFirstByFieldsSucceedsWhenMultipleOrderedDocumentsAreFound(): void + { + $doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class, + orderBy: [Field::named('n:num_value DESC')]); + $this->assertTrue($doc->isSome(), 'There should have been a document returned'); + $this->assertEquals('four', $doc->get()->id, 'The incorrect document was returned'); + } + #[TestDox('firstByFields() succeeds when a document is not found')] public function testFirstByFieldsSucceedsWhenADocumentIsNotFound(): void { @@ -206,6 +273,15 @@ class FindTest extends TestCase $this->assertContains($doc->get()->id, ['four', 'five'], 'An incorrect document was returned'); } + #[TestDox('firstByContains() succeeds when multiple ordered documents are found')] + public function testFirstByContainsSucceedsWhenMultipleOrderedDocumentsAreFound(): void + { + $doc = Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'purple'], TestDocument::class, + [Field::named('sub.bar NULLS FIRST')]); + $this->assertTrue($doc->isSome(), 'There should have been a document returned'); + $this->assertEquals('five', $doc->get()->id, 'The incorrect document was returned'); + } + #[TestDox('firstByContains() succeeds when a document is not found')] public function testFirstByContainsSucceedsWhenADocumentIsNotFound(): void { @@ -229,6 +305,15 @@ class FindTest extends TestCase $this->assertContains($doc->get()->id, ['four', 'five'], 'An incorrect document was returned'); } + #[TestDox('firstByJsonPath() succeeds when multiple ordered documents are found')] + public function testFirstByJsonPathSucceedsWhenMultipleOrderedDocumentsAreFound(): void + { + $doc = Find::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class, + [Field::named('id DESC')]); + $this->assertTrue($doc->isSome(), 'There should have been a document returned'); + $this->assertEquals('four', $doc->get()->id, 'The incorrect document was returned'); + } + #[TestDox('firstByJsonPath() succeeds when a document is not found')] public function testFirstByJsonPathSucceedsWhenADocumentIsNotFound(): void { diff --git a/tests/integration/sqlite/FindTest.php b/tests/integration/sqlite/FindTest.php index 754610d..8f7a42d 100644 --- a/tests/integration/sqlite/FindTest.php +++ b/tests/integration/sqlite/FindTest.php @@ -45,6 +45,34 @@ class FindTest extends TestCase $this->assertEquals(5, $count, 'There should have been 5 documents in the list'); } + #[TestDox('all() succeeds when ordering data ascending')] + public function testAllSucceedsWhenOrderingDataAscending(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['five', 'four', 'one', 'three', 'two'], $ids, 'The documents were not ordered correctly'); + } + + #[TestDox('all() succeeds when ordering data descending')] + public function testAllSucceedsWhenOrderingDataDescending(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id DESC')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['two', 'three', 'one', 'four', 'five'], $ids, 'The documents were not ordered correctly'); + } + + #[TestDox('all() succeeds when ordering data numerically')] + public function testAllSucceedsWhenOrderingDataNumerically(): void + { + $docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, + [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['two', 'four', 'one', 'three', 'five'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('all() succeeds when there is no data')] public function testAllSucceedsWhenThereIsNoData(): void { @@ -89,8 +117,18 @@ class FindTest extends TestCase $this->assertEquals(1, $count, 'There should have been 1 document in the list'); } + #[TestDox('byFields() succeeds when documents are found and ordered')] + public function testByFieldsSucceedsWhenDocumentsAreFoundAndOrdered(): void + { + $docs = Find::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], TestDocument::class, + FieldMatch::All, [Field::named('id')]); + $this->assertNotNull($docs, 'There should have been a document list returned'); + $ids = iterator_to_array($docs->map(fn ($it) => $it->id), false); + $this->assertEquals(['five', 'four'], $ids, 'The documents were not ordered correctly'); + } + #[TestDox('byFields() succeeds when documents are found using IN with numeric field')] - public function testByFieldsSucceedsWhenDocumentsAreFoundUsingInWithNumericField() + public function testByFieldsSucceedsWhenDocumentsAreFoundUsingInWithNumericField(): void { $docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])], TestDocument::class); $this->assertNotNull($docs, 'There should have been a document list returned'); @@ -161,6 +199,15 @@ class FindTest extends TestCase $this->assertContains($doc->get()->id, ['two', 'four'], 'An incorrect document was returned'); } + #[TestDox('firstByFields() succeeds when multiple ordered documents are found')] + public function testFirstByFieldsSucceedsWhenMultipleOrderedDocumentsAreFound(): void + { + $doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class, + orderBy: [Field::named('n:num_value DESC')]); + $this->assertTrue($doc->isSome(), 'There should have been a document returned'); + $this->assertEquals('four', $doc->get()->id, 'The incorrect document was returned'); + } + #[TestDox('firstByFields() succeeds when a document is not found')] public function testFirstByFieldsSucceedsWhenADocumentIsNotFound(): void { diff --git a/tests/unit/FieldTest.php b/tests/unit/FieldTest.php index 6ab1fe6..4bf06ec 100644 --- a/tests/unit/FieldTest.php +++ b/tests/unit/FieldTest.php @@ -82,7 +82,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for simple SQL path for PostgreSQL')] - public function testPathSucceedsForSimpleSqlPathForPostgreSQL() + public function testPathSucceedsForSimpleSqlPathForPostgreSQL(): void { Configuration::overrideMode(Mode::PgSQL); try { @@ -94,7 +94,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for simple SQL path for SQLite')] - public function testPathSucceedsForSimpleSqlPathForSQLite() + public function testPathSucceedsForSimpleSqlPathForSQLite(): void { Configuration::overrideMode(Mode::SQLite); try { @@ -106,7 +106,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for nested SQL path for PostgreSQL')] - public function testPathSucceedsForNestedSqlPathForPostgreSQL() + public function testPathSucceedsForNestedSqlPathForPostgreSQL(): void { Configuration::overrideMode(Mode::PgSQL); try { @@ -118,7 +118,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for nested SQL path for SQLite')] - public function testPathSucceedsForNestedSqlPathForSQLite() + public function testPathSucceedsForNestedSqlPathForSQLite(): void { Configuration::overrideMode(Mode::SQLite); try { @@ -130,7 +130,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for simple JSON path for PostgreSQL')] - public function testPathSucceedsForSimpleJsonPathForPostgreSQL() + public function testPathSucceedsForSimpleJsonPathForPostgreSQL(): void { Configuration::overrideMode(Mode::PgSQL); try { @@ -142,7 +142,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for simple JSON path for SQLite')] - public function testPathSucceedsForSimpleJsonPathForSQLite() + public function testPathSucceedsForSimpleJsonPathForSQLite(): void { Configuration::overrideMode(Mode::SQLite); try { @@ -154,7 +154,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for nested JSON path for PostgreSQL')] - public function testPathSucceedsForNestedJsonPathForPostgreSQL() + public function testPathSucceedsForNestedJsonPathForPostgreSQL(): void { Configuration::overrideMode(Mode::PgSQL); try { @@ -166,7 +166,7 @@ class FieldTest extends TestCase } #[TestDox('path() succeeds for nested JSON path for SQLite')] - public function testPathSucceedsForNestedJsonPathForSQLite() + public function testPathSucceedsForNestedJsonPathForSQLite(): void { Configuration::overrideMode(Mode::SQLite); try { @@ -669,4 +669,15 @@ class FieldTest extends TestCase $this->assertEquals('', $field->value, 'Value should have been blank'); $this->assertEquals('', $field->paramName, 'Parameter name should have been blank'); } + + #[TestDox('named() succeeds')] + public function testNamedSucceeds(): void + { + $field = Field::named('the_field'); + $this->assertNotNull($field, 'The field should not have been null'); + $this->assertEquals('the_field', $field->fieldName, 'Field name not filled correctly'); + $this->assertEquals(Op::Equal, $field->op, 'Operation not filled correctly'); + $this->assertEquals('', $field->value, 'Value should have been blank'); + $this->assertEquals('', $field->paramName, 'Parameter name should have been blank'); + } } diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index f325e38..66eb63f 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -224,4 +224,81 @@ class QueryTest extends TestCase $this->assertEquals("UPDATE testing SET data = :data WHERE data->>'id' = :id", Query::update('testing'), 'UPDATE statement not constructed correctly'); } + + #[TestDox('orderBy() succeeds with no fields for PostgreSQL')] + public function testOrderBySucceedsWithNoFieldsForPostgreSQL(): void + { + Configuration::overrideMode(Mode::PgSQL); + $this->assertEquals('', Query::orderBy([]), 'ORDER BY should have been blank'); + } + + #[TestDox('orderBy() succeeds with no fields for SQLite')] + public function testOrderBySucceedsWithNoFieldsForSQLite(): void + { + $this->assertEquals('', Query::orderBy([]), 'ORDER BY should have been blank'); + } + + #[TestDox('orderBy() succeeds with one field and no direction for PostgreSQL')] + public function testOrderBySucceedsWithOneFieldAndNoDirectionForPostgreSQL(): void + { + Configuration::overrideMode(Mode::PgSQL); + $this->assertEquals(" ORDER BY data->>'TestField'", Query::orderBy([Field::named('TestField')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with one field and no direction for SQLite')] + public function testOrderBySucceedsWithOneFieldAndNoDirectionForSQLite(): void + { + $this->assertEquals(" ORDER BY data->>'TestField'", Query::orderBy([Field::named('TestField')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with multiple fields and direction for PostgreSQL')] + public function testOrderBySucceedsWithMultipleFieldsAndDirectionForPostgreSQL(): void + { + Configuration::overrideMode(Mode::PgSQL); + $this->assertEquals(" ORDER BY data#>>'{Nested,Test,Field}' DESC, data->>'AnotherField', data->>'It' DESC", + Query::orderBy( + [Field::named('Nested.Test.Field DESC'), Field::named('AnotherField'), Field::named('It DESC')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with multiple fields and direction for SQLite')] + public function testOrderBySucceedsWithMultipleFieldsAndDirectionForSQLite(): void + { + $this->assertEquals(" ORDER BY data->'Nested'->'Test'->>'Field' DESC, data->>'AnotherField', data->>'It' DESC", + Query::orderBy( + [Field::named('Nested.Test.Field DESC'), Field::named('AnotherField'), Field::named('It DESC')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with numeric field for PostgreSQL')] + public function testOrderBySucceedsWithNumericFieldForPostgreSQL(): void + { + Configuration::overrideMode(Mode::PgSQL); + $this->assertEquals(" ORDER BY (data->>'Test')::numeric", Query::orderBy([Field::named('n:Test')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with numeric field for SQLite')] + public function testOrderBySucceedsWithNumericFieldForSQLite(): void + { + $this->assertEquals(" ORDER BY data->>'Test'", Query::orderBy([Field::named('n:Test')]), + 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with case-insensitive ordering for PostgreSQL')] + public function testOrderBySucceedsWithCaseInsensitiveOrderingForPostgreSQL(): void + { + Configuration::overrideMode(Mode::PgSQL); + $this->assertEquals(" ORDER BY LOWER(data#>>'{Test,Field}') DESC NULLS FIRST", + Query::orderBy([Field::named('i:Test.Field DESC NULLS FIRST')]), 'ORDER BY not constructed correctly'); + } + + #[TestDox('orderBy() succeeds with case-insensitive ordering for SQLite')] + public function testOrderBySucceedsWithCaseInsensitiveOrderingForSQLite(): void + { + $this->assertEquals(" ORDER BY data->'Test'->>'Field' COLLATE NOCASE ASC NULLS LAST", + Query::orderBy([Field::named('i:Test.Field ASC NULLS LAST')]), 'ORDER BY not constructed correctly'); + } }