v2.1 #10

Merged
danieljsummers merged 6 commits from v2point1 into main 2025-04-25 01:17:53 +00:00
15 changed files with 1449 additions and 262 deletions

View File

@ -4,8 +4,8 @@ This library allows SQLite and PostgreSQL to be treated as document databases. I
## Add via Composer ## Add via Composer
[![Static Badge](https://img.shields.io/badge/v1.0.0--rc1-orange?label=php%208.2) [![v1 Packagist Version](https://img.shields.io/badge/v1.1.0-blue?label=php%208.2)
](https://packagist.org/packages/bit-badger/pdo-document#v1.0.0-rc1)     [![Packagist Version](https://img.shields.io/packagist/v/bit-badger/pdo-document?include_prereleases&label=php%208.4) ](https://packagist.org/packages/bit-badger/pdo-document#v1.1.0-rc1)     [![Packagist Version](https://img.shields.io/packagist/v/bit-badger/pdo-document?include_prereleases&label=php%208.4)
](https://packagist.org/packages/bit-badger/pdo-document) ](https://packagist.org/packages/bit-badger/pdo-document)
`composer require bit-badger/pdo-document` `composer require bit-badger/pdo-document`
@ -33,4 +33,4 @@ In all generated scenarios, if the ID value is not 0 or blank, that ID will be u
## Usage ## Usage
Full documentation [is available on the project site](https://bitbadger.solutions/open-source/pdo-document/). Full documentation [is available on the project site](https://relationaldocs.bitbadger.solutions/php/).

538
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@ There are several categories of operations that can be accomplished against docu
- **Save** adds a new document, updating an existing one if the ID is already present ("upsert") - **Save** adds a new document, updating an existing one if the ID is already present ("upsert")
- **Update** updates an existing document, doing nothing if no documents satisfy the criteria - **Update** updates an existing document, doing nothing if no documents satisfy the criteria
- **Patch** updates a portion of an existing document, doing nothing if no documents satisfy the criteria - **Patch** updates a portion of an existing document, doing nothing if no documents satisfy the criteria
- **Find** returns the documents matching some criteria - **Find** returns the documents matching some criteria as domain objects
- **Json** returns documents as JSON strings or outputs that JSON directly
- **RemoveFields** removes fields from documents matching some criteria - **RemoveFields** removes fields from documents matching some criteria
- **Delete** removes documents matching some criteria - **Delete** removes documents matching some criteria
@ -23,7 +24,7 @@ There are several categories of operations that can be accomplished against docu
- **byContains** (PostgreSQL only) uses a JSON containment query (the `@>` operator) to find documents where the given sub-document occurs (think of this as an `=` comparison based on one or more properties in the document; looking for hotels with `{ "country": "USA", "rating": 4 }` would find all hotels with a rating of 4 in the United States); applies to all but Update - **byContains** (PostgreSQL only) uses a JSON containment query (the `@>` operator) to find documents where the given sub-document occurs (think of this as an `=` comparison based on one or more properties in the document; looking for hotels with `{ "country": "USA", "rating": 4 }` would find all hotels with a rating of 4 in the United States); applies to all but Update
- **byJsonPath** (PostgreSQL only) uses a JSON patch match query (the `@?` operator) to make specific queries against a document's structure (it also supports more operators than a containment query; to find all hotels rated 4 _or higher_ in the United States, we could query for `"$ ? (@.country == \"USA\" && @.rating > 4)"`); applies to all but Update - **byJsonPath** (PostgreSQL only) uses a JSON patch match query (the `@?` operator) to make specific queries against a document's structure (it also supports more operators than a containment query; to find all hotels rated 4 _or higher_ in the United States, we could query for `"$ ? (@.country == \"USA\" && @.rating > 4)"`); applies to all but Update
Finally, `Find` also has `firstBy*` implementations for all supported criteria types. Finally, `Find` and `Json` also has `firstBy*` implementations for all supported criteria types.
## Saving Documents ## Saving Documents
@ -62,10 +63,15 @@ For SQLite, we can utilize a `Field` query with a between operator. (This will a
```php ```php
// SQLite // SQLite
Patch::byFields('room', [Field::between('roomNumber', 221, 240)], ['inService' => false]); Patch::byFields('room',
[Field::equal('hotelId', 'abc'), Field::between('roomNumber', 221, 240)],
['inService' => false]);
``` ```
## Finding Documents > [!NOTE]
> When multiple fields are provided to any `*byFields` function, the default matching behavior is `FieldMatch.All`; any documents identified will match all criteria. Passing `FieldMatch.Any` modifies this behavior to identify documents where any one of the criteria match.
## Finding Documents as Domain Items
Functions to find documents start with `Find::`. There are variants to find all documents in a table, find by ID, find by JSON field comparison, find by JSON containment, or find by JSON Path. The hotel update example above utilizes an ID lookup; the descriptions of JSON containment and JSON Path show examples of the criteria used to retrieve using those techniques. Functions to find documents start with `Find::`. There are variants to find all documents in a table, find by ID, find by JSON field comparison, find by JSON containment, or find by JSON Path. The hotel update example above utilizes an ID lookup; the descriptions of JSON containment and JSON Path show examples of the criteria used to retrieve using those techniques.
@ -109,6 +115,13 @@ foreach ($result->items as $item) {
// Do something amazing with $item // Do something amazing with $item
} }
``` ```
## Finding Documents as JSON
If an application serves endpoints that return JSON, taking the time to retrieve documents, deserialize them into objects, then turning around and serializing those same documents back to JSON - there's a lot of unnecessary processing going on. Static functions on the `Json` object address this by returning, or directly outputting, the JSON text received from the database.
`Json` has the same function names as `Find`, and these functions return the JSON as a string. These always return some string with valid JSON; an empty multiple-document request will be `[]`, and a one-or-none document request will be `{}` in the "none" scenario.
A second set of functions are prefixed with `output` (ex. `Json::outputAll`); these functions `echo` the JSON as it is retrieved from the database. This is the preferred method for JSON APIs, as it incurs no intermediate overhead; take the documents from the database and ship 'em off to the distant end. As these responses end up as a stream of text, we will need to identify this as JSON; `Json::setContentType()` will do this, and should be called before any other output is sent.
## Deleting Documents ## Deleting Documents
@ -126,14 +139,14 @@ Functions to check for existence start with `Exists::`. Documents may be checked
The table below shows which commands are available for each access method. (X = supported for both, P = PostgreSQL only) The table below shows which commands are available for each access method. (X = supported for both, P = PostgreSQL only)
| Operation | `all` | `byId` | `byFields` | `byContains` | `byJsonPath` | `firstByFields` | `firstByContains` | `firstByJsonPath` | | Operation | `all` | `byId` | `byFields` | `byContains` | `byJsonPath` | `firstByFields` | `firstByContains` | `firstByJsonPath` |
|----------------|:-----:|:------:|:----------:|:------------:|:------------:|:---------------:|:-----------------:|:-----------------:| |-----------------|:-----:|:------:|:----------:|:------------:|:------------:|:---------------:|:-----------------:|:-----------------:|
| `Count` | X | | X | P | P | | | | | `Count` | X | | X | P | P | | | |
| `Exists` | | X | X | P | P | | | | | `Exists` | | X | X | P | P | | | |
| `Find` | X | X | X | P | P | X | P | P | | `Find` / `Json` | X | X | X | P | P | X | P | P |
| `Patch` | | X | X | P | P | | | | | `Patch` | | X | X | P | P | | | |
| `RemoveFields` | | X | X | P | P | | | | | `RemoveFields` | | X | X | P | P | | | |
| `Delete` | | X | X | P | P | | | | | `Delete` | | X | X | P | P | | | |
`Document::insert`, `Document::save`, and `Document::update` operate on single documents. `Document::insert`, `Document::save`, and `Document::update` operate on single documents.

View File

@ -7,8 +7,8 @@ PDODocument is a PHP library that implements [relational document](/) concepts o
## Installing ## Installing
[![v1 Packagist Version](https://img.shields.io/badge/v1.0.0-blue?label=php%208.2) [![v1 Packagist Version](https://img.shields.io/badge/v1.1.0-blue?label=php%208.2)
](https://packagist.org/packages/bit-badger/pdo-document#v1.0.0)     [![Packagist Version](https://img.shields.io/packagist/v/bit-badger/pdo-document?include_prereleases&label=php%208.4) ](https://packagist.org/packages/bit-badger/pdo-document#v1.1.0)     [![Packagist Version](https://img.shields.io/packagist/v/bit-badger/pdo-document?include_prereleases&label=php%208.4)
](https://packagist.org/packages/bit-badger/pdo-document) ](https://packagist.org/packages/bit-badger/pdo-document)
The library is [listed on Packagist][pkg] as `bit-badger/pdo-document`. v1.x targets PHP 8.2 and 8.3, while v2.x targets PHP 8.4 and up. Run `composer require bit-badger/pdo-document` (or add it to your `composer.json` manually), and it should select the appropriate version based on the target PHP version of your project. The library is [listed on Packagist][pkg] as `bit-badger/pdo-document`. v1.x targets PHP 8.2 and 8.3, while v2.x targets PHP 8.4 and up. Run `composer require bit-badger/pdo-document` (or add it to your `composer.json` manually), and it should select the appropriate version based on the target PHP version of your project.

View File

@ -10,6 +10,7 @@ namespace BitBadger\PDODocument;
use BitBadger\InspiredByFSharp\Option; use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\Mapper\Mapper; use BitBadger\PDODocument\Mapper\Mapper;
use BitBadger\PDODocument\Mapper\StringMapper;
use PDO; use PDO;
use PDOException; use PDOException;
use PDOStatement; use PDOStatement;
@ -93,7 +94,42 @@ class Custom
} }
/** /**
* Execute a query that returns one or no results (returns false if not found) * Execute a query that returns a JSON string of results
*
* @param string $query The query to be executed
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @return string A JSON array with the results (empty results will be `[]`)
* @throws DocumentException If any is encountered
*/
public static function jsonArray(string $query, array $parameters): string
{
return '[' . implode(',', self::array($query, $parameters, new StringMapper('data'))) . ']';
}
/**
* Execute a query, echoing the results to the output
*
* @param string $query The query to be executed
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @throws DocumentException If any is encountered
*/
public static function outputJsonArray(string $query, array $parameters): void
{
$isFirst = true;
echo '[';
foreach (self::list($query, $parameters, new StringMapper('data'))->items as $doc) {
if ($isFirst) {
$isFirst = false;
} else {
echo ',';
}
echo $doc;
}
echo ']';
}
/**
* Execute a query that returns one or no results
* *
* @template TDoc The domain type of the document to retrieve * @template TDoc The domain type of the document to retrieve
* @param string $query The query to be executed (will have "LIMIT 1" appended) * @param string $query The query to be executed (will have "LIMIT 1" appended)
@ -112,6 +148,19 @@ class Custom
} }
} }
/**
* Execute a query that returns one or no JSON results
*
* @param string $query The query to be executed (will have "LIMIT 1" appended)
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @return string The JSON document (returns `{}` if no document is found)
* @throws DocumentException If any is encountered
*/
public static function jsonSingle(string $query, array $parameters): string
{
return self::single($query, $parameters, new StringMapper('data'))->getOrDefault('{}');
}
/** /**
* Execute a query that does not return a value * Execute a query that does not return a value
* *

View File

@ -12,7 +12,7 @@ use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\Mapper\DocumentMapper; use BitBadger\PDODocument\Mapper\DocumentMapper;
/** /**
* Functions to find documents * Functions to retrieve documents as domain objects
*/ */
class Find class Find
{ {

252
src/Json.php Normal file
View File

@ -0,0 +1,252 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace BitBadger\PDODocument;
/**
* Functions to retrieve and output documents as JSON
*/
class Json
{
/**
* Retrieve all JSON documents in the given table
*
* @param string $tableName The table from which documents should be retrieved
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @return string A JSON array of all documents from the table
* @throws DocumentException If any is encountered
*/
public static function all(string $tableName, array $orderBy = []): string
{
return Custom::jsonArray(Query::selectFromTable($tableName) . Query::orderBy($orderBy), []);
}
/**
* Retrieve a JSON document by its ID
*
* @param string $tableName The table from which the document should be retrieved
* @param mixed $docId The ID of the document to retrieve
* @return string The JSON document if found, `{}` otherwise
* @throws DocumentException If any is encountered
*/
public static function byId(string $tableName, mixed $docId): string
{
return Custom::jsonSingle(Query\Find::byId($tableName, $docId), Parameters::id($docId));
}
/**
* Retrieve JSON documents via a comparison on JSON fields
*
* @param string $tableName The table from which documents should be retrieved
* @param Field[] $fields The field comparison to match
* @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 string A JSON array of documents matching the given field comparison
* @throws DocumentException If any is encountered
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null,
array $orderBy = []): string
{
Parameters::nameFields($fields);
return Custom::jsonArray(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy),
Parameters::addFields($fields, []));
}
/**
* Retrieve JSON documents via a JSON containment query (`@>`; PostgreSQL only)
*
* @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 Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @return string A JSON array 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, array $orderBy = []): string
{
return Custom::jsonArray(Query\Find::byContains($tableName) . Query::orderBy($orderBy),
Parameters::json(':criteria', $criteria));
}
/**
* Retrieve JSON documents via a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @param string $path The JSON Path match string
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @return string A JSON array 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, array $orderBy = []): string
{
return Custom::jsonArray(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path]);
}
/**
* Retrieve JSON documents via a comparison on JSON fields, returning only the first result
*
* @param string $tableName The table from which the document should be retrieved
* @param Field[] $fields The field comparison to match
* @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 string The first JSON document if any matches are found, `{}` otherwise
* @throws DocumentException If any is encountered
*/
public static function firstByFields(string $tableName, array $fields, ?FieldMatch $match = null,
array $orderBy = []): string
{
Parameters::nameFields($fields);
return Custom::jsonSingle(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy),
Parameters::addFields($fields, []));
}
/**
* Retrieve JSON documents via a JSON containment query (`@>`), returning only the first result (PostgreSQL only)
*
* @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 Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @return string The first JSON document if any matches are found, `{}` otherwise
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function firstByContains(string $tableName, array|object $criteria, array $orderBy = []): string
{
return Custom::jsonSingle(Query\Find::byContains($tableName) . Query::orderBy($orderBy),
Parameters::json(':criteria', $criteria));
}
/**
* Retrieve JSON documents via a JSON Path match query (`@?`), returning only the first result (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @param string $path The JSON Path match string
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @return string The first JSON document if any matches are found, `{}` otherwise
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function firstByJsonPath(string $tableName, string $path, array $orderBy = []): string
{
return Custom::jsonSingle(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path]);
}
/**
* Output all JSON documents in the given table
*
* @param string $tableName The table from which documents should be retrieved
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @throws DocumentException If any is encountered
*/
public static function outputAll(string $tableName, array $orderBy = []): void
{
Custom::outputJsonArray(Query::selectFromTable($tableName) . Query::orderBy($orderBy), []);
}
/**
* Output a JSON document by its ID
*
* @param string $tableName The table from which the document should be retrieved
* @param mixed $docId The ID of the document to retrieve
* @throws DocumentException If any is encountered
*/
public static function outputById(string $tableName, mixed $docId): void
{
echo self::byId($tableName, $docId);
}
/**
* Output JSON documents via a comparison on JSON fields
*
* @param string $tableName The table from which documents should be retrieved
* @param Field[] $fields The field comparison to match
* @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)
* @throws DocumentException If any is encountered
*/
public static function outputByFields(string $tableName, array $fields, ?FieldMatch $match = null,
array $orderBy = []): void
{
Parameters::nameFields($fields);
Custom::outputJsonArray(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy),
Parameters::addFields($fields, []));
}
/**
* Output JSON documents via a JSON containment query (`@>`; PostgreSQL only)
*
* @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 Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function outputByContains(string $tableName, array|object $criteria, array $orderBy = []): void
{
Custom::outputJsonArray(Query\Find::byContains($tableName) . Query::orderBy($orderBy),
Parameters::json(':criteria', $criteria));
}
/**
* Output JSON documents via a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @param string $path The JSON Path match string
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function outputByJsonPath(string $tableName, string $path, array $orderBy = []): void
{
Custom::outputJsonArray(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path]);
}
/**
* Output JSON documents via a comparison on JSON fields, returning only the first result
*
* @param string $tableName The table from which the document should be retrieved
* @param Field[] $fields The field comparison to match
* @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)
* @throws DocumentException If any is encountered
*/
public static function outputFirstByFields(string $tableName, array $fields, ?FieldMatch $match = null,
array $orderBy = []): void
{
echo self::firstByFields($tableName, $fields, $match, $orderBy);
}
/**
* Output JSON documents via a JSON containment query (`@>`), returning only the first result (PostgreSQL only)
*
* @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 Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function outputFirstByContains(string $tableName, array|object $criteria, array $orderBy = []): void
{
echo self::firstByContains($tableName, $criteria, $orderBy);
}
/**
* Output JSON documents via a JSON Path match query (`@?`), returning only the first result (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @param string $path The JSON Path match string
* @param Field[] $orderBy Fields by which the results should be ordered (optional, default no ordering)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function outputFirstByJsonPath(string $tableName, string $path, array $orderBy = []): void
{
echo self::firstByJsonPath($tableName, $path, $orderBy);
}
/**
* Set the content type of this page's output to JSON
*/
public static function setContentType(): void
{
header('Content-Type: application/json; charset=UTF-8');
}
}

23
test_all.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
export PDO_DOC_PGSQL_HOST=localhost:8301
PG_VERSIONS=('13' '14' '15' '16' 'latest')
for PG_VERSION in "${PG_VERSIONS[@]}"
do
echo Starting PostgreSQL:$PG_VERSION
docker run -d -p 8301:5432 --name pg_test -e POSTGRES_PASSWORD=postgres postgres:$PG_VERSION
sleep 4
if [ "$PG_VERSION" = "latest" ]; then
echo Testing SQLite and PostgreSQL...
./vendor/bin/pest --testdox-text $1-tests.txt tests
else
echo Testing PostgreSQL v$PG_VERSION...
./vendor/bin/pest --testdox-text $1-pg$PG_VERSION-tests.txt tests/Integration/PostgreSQL
fi
docker stop pg_test
sleep 2
docker rm pg_test
done
cd .. || exit

View File

@ -0,0 +1,38 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
* @see https://github.com/Zaid-Ajaj/ThrowawayDb The origin concept
*/
declare(strict_types=1);
namespace Test\Integration;
use PHPUnit\Framework\TestCase;
/**
* Base test case class for document integration tests
*/
class DocumentTestCase extends TestCase
{
/**
* Clear the output buffer
*/
public function clearBuffer(): void
{
ob_clean();
ob_start();
}
/**
* Get the contents of the output buffer and end buffering
*
* @return string The contents of the output buffer
*/
public function getBufferContents(): string {
$contents = ob_get_contents();
ob_end_clean();
return $contents;
}
}

View File

@ -11,13 +11,12 @@ namespace Test\Integration;
use BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field}; use BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field};
use BitBadger\PDODocument\Mapper\ExistsMapper; use BitBadger\PDODocument\Mapper\ExistsMapper;
use PHPUnit\Framework\TestCase;
use Test\Integration\PostgreSQL\ThrowawayDb; use Test\Integration\PostgreSQL\ThrowawayDb;
/** /**
* Integration Test Class wrapper for PostgreSQL integration tests * Integration Test Class wrapper for PostgreSQL integration tests
*/ */
class PgIntegrationTest extends TestCase class PgIntegrationTest extends DocumentTestCase
{ {
/** @var string Database name for throwaway database */ /** @var string Database name for throwaway database */
static private string $dbName = ''; static private string $dbName = '';

View File

@ -65,6 +65,30 @@ describe('::array()', function () {
}); });
}); });
describe('::jsonArray()', function () {
test('returns non-empty array when data found', function () {
expect(Custom::jsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", []))
->toContain('[{', '},{', '}]');
});
test('returns empty array when no data found', function () {
expect(Custom::jsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'nothing' = '7'", []))
->toBe('[]');
});
});
describe('::outputJsonArray()', function () {
test('outputs non-empty array when data found', function () {
$this->clearBuffer();
Custom::outputJsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", []);
expect($this->getBufferContents())->toContain('[{', '},{', '}]');
});
test('outputs empty array when no data found', function () {
$this->clearBuffer();
Custom::outputJsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'nothing' = '7'", []);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::single()', function () { describe('::single()', function () {
test('returns a document when one is found', function () { test('returns a document when one is found', function () {
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'], $doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'],
@ -78,6 +102,19 @@ describe('::single()', function () {
}); });
}); });
describe('::jsonSingle()', function () {
test('returns a document when one is found', function () {
expect(Custom::jsonSingle('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'one']))
->toStartWith('{"id":')->toContain('"one",')->toEndWith('}');
});
test('returns no document when one is not found', function () {
expect(Custom::jsonSingle('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'eighty']))
->toBe('{}');
});
});
describe('::nonQuery()', function () { describe('::nonQuery()', function () {
test('works when documents match the WHERE clause', function () { test('works when documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []); Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);

View File

@ -0,0 +1,406 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Delete, Document, Field, FieldMatch, Json};
use Test\Integration\ArrayDocument;
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
// NOTE: PostgreSQL's `JSONB` type stores JSON in a binary representation which is more easily indexed and stored. One
// side effect of this is that the document returned may not have fields in the same order as the document which
// was originally stored. The ID will always be first, though, so these tests check for presence of IDs, sorting
// by IDs, etc., instead of checking the entire JSON string that should be either returned or output.
/**
* Expect document ordering by verifying the index of IDs against others
*
* @param string $json The JSON string to be searched
* @param array $ids The IDs to be verified
*/
function expect_doc_order(string $json, array $ids): void
{
for ($idx = 0; $idx < sizeof($ids) - 1; $idx++) {
expect(strpos($json, '"' . $ids[$idx] . '",'))
->toBeLessThan(strpos($json, '"' . $ids[$idx + 1] . '",'),
"ID $ids[$idx] should have occurred before ID {$ids[$idx + 1]} in JSON $json");
}
}
/**
* Expect to find one of several document IDs in the given JSON
* @param string $json The JSON string to be searched
* @param string ...$ids One or more IDs to be searched
*/
function expect_any(string $json, string... $ids): void
{
expect(array_any($ids, fn($it) => strpos($json, '"id": "' . $it . '",') >= 0))
->toBeTrue('Could not find any of IDs [' . implode(', ', $ids) . "] in $json");
}
describe('::all()', function () {
test('retrieves data', function () {
expect(Json::all(ThrowawayDb::TABLE))
->toContain('{"id": "one",', '{"id": "two",', '{"id": "three",', '{"id": "four",', '{"id": "five",');
});
test('sorts data ascending', function () {
expect_doc_order(Json::all(ThrowawayDb::TABLE, [Field::named('id')]), ['five', 'four', 'one', 'three', 'two']);
});
test('sorts data descending', function () {
expect_doc_order(Json::all(ThrowawayDb::TABLE, [Field::named('id DESC')]),
['two', 'three', 'one', 'four', 'five']);
});
test('sorts data numerically', function () {
expect_doc_order(
Json::all(ThrowawayDb::TABLE, [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]),
['two', 'four', 'one', 'three', 'five']);
});
test('retrieves empty results', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Json::all(ThrowawayDb::TABLE))->toBe('[]');
});
});
describe('::byId()', function () {
test('retrieves a document via string ID', function () {
expect(Json::byId(ThrowawayDb::TABLE, 'two'))->toStartWith('{"id": "two",')->toEndWith('}');
});
test('retrieves a document via numeric ID', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absent')]);
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
expect(Json::byId(ThrowawayDb::TABLE, 18))->toStartWith('{"id": 18,')->toEndWith('}');
});
test('returns "{}" when a document is not found', function () {
expect(Json::byId(ThrowawayDb::TABLE, 'seventy-five'))->toBe('{}');
});
});
describe('::byFields()', function () {
test('retrieves matching documents', function () {
expect(
Json::byFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
FieldMatch::All))
->toStartWith('[{"id": "four",')->toEndWith('}]');
});
test('retrieves ordered matching documents', function () {
expect_doc_order(
Json::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], FieldMatch::All,
[Field::named('id')]),
['five', 'four']);
});
test('retrieves documents matching a numeric IN clause', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])]))
->toStartWith('[{"id": "three",')->toEndWith('}]');
});
test('returns an empty array when no matching documents are found', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)]))->toBe('[]');
});
test('retrieves documents matching an inArray condition', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
expect(Json::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])]))
->toStartWith('[')->toContain('{"id": "first",')->toContain('{"id": "second",')->toEndWith(']');
});
test('returns an empty array when no documents match an inArray condition', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
expect(Json::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])]))
->toBe('[]');
});
});
describe('::byContains()', function () {
test('retrieves matching documents', function () {
expect(Json::byContains(ThrowawayDb::TABLE, ['value' => 'purple']))
->toStartWith('[')->toContain('{"id": "four",', '{"id": "five",')->toEndWith(']');
});
test('retrieves ordered matching documents', function () {
expect_doc_order(Json::byContains(ThrowawayDb::TABLE, ['sub' => ['foo' => 'green']], [Field::named('value')]),
['two', 'four']);
});
test('returns an empty array when no documents match', function () {
expect(Json::byContains(ThrowawayDb::TABLE, ['value' => 'indigo']))->toBe('[]');
});
});
describe('::byJsonPath()', function () {
test('retrieves matching documents', function () {
expect(Json::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)'))
->toStartWith('[')->toContain('{"id": "four",', '{"id": "five",')->toEndWith(']');
});
test('retrieves ordered matching documents', function () {
expect_doc_order(Json::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', [Field::named('id')]),
['five', 'four']);
});
test('returns an empty array when no documents match', function () {
expect(Json::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)'))->toBe('[]');
});
});
describe('::firstByFields()', function () {
test('retrieves a matching document', function () {
expect(Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')]))
->toStartWith('{"id": "two",')->toEndWith('}');
});
test('retrieves a document for multiple results', function () {
$doc = Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')]);
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'two', 'four');
});
test('retrieves a document for multiple ordered results', function () {
expect(
Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')],
orderBy: [Field::named('n:num_value DESC')]))
->toStartWith('{"id": "four",')->toEndWith('}');
});
test('returns "{}" when no documents match', function () {
expect(Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')]))->toBe('{}');
});
});
describe('::firstByContains()', function () {
test('retrieves a matching document', function () {
expect(Json::firstByContains(ThrowawayDb::TABLE, ['value' => 'FIRST!']))
->toStartWith('{"id": "one",')->toEndWith('}');
});
test('retrieves a document for multiple results', function () {
$doc = Json::firstByContains(ThrowawayDb::TABLE, ['value' => 'purple']);
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'four', 'five');
});
test('retrieves a document for multiple ordered results', function () {
expect(Json::firstByContains(ThrowawayDb::TABLE, ['value' => 'purple'], [Field::named('sub.bar NULLS FIRST')]))
->toStartWith('{"id": "five",')->toEndWith('}');
});
test('returns "{}" when no documents match', function () {
expect(Json::firstByContains(ThrowawayDb::TABLE, ['value' => 'indigo']))->toBe('{}');
});
});
describe('::firstByJsonPath()', function () {
test('retrieves a matching document', function () {
expect(Json::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ == 10)'))
->toStartWith('{"id": "two",')->toEndWith('}');
});
test('retrieves a document for multiple results', function () {
$doc = Json::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)');
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'four', 'five');
});
test('retrieves a document for multiple ordered results', function () {
expect(Json::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', [Field::named('id DESC')]))
->toStartWith('{"id": "four",')->toEndWith('}');
});
test('returns "{}" when no documents match', function () {
expect(Json::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)'))->toBe('{}');
});
});
describe('::outputAll()', function () {
test('outputs data', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE);
expect($this->getBufferContents())
->toContain('{"id": "one",', '{"id": "two",', '{"id": "three",', '{"id": "four",', '{"id": "five",');
});
test('sorts data ascending', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('id')]);
expect_doc_order($this->getBufferContents(), ['five', 'four', 'one', 'three', 'two']);
});
test('sorts data descending', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('id DESC')]);
expect_doc_order($this->getBufferContents(), ['two', 'three', 'one', 'four', 'five']);
});
test('sorts data numerically', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]);
expect_doc_order($this->getBufferContents(), ['two', 'four', 'one', 'three', 'five']);
});
test('outputs empty results', function () {
$this->clearBuffer();
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Json::outputAll(ThrowawayDb::TABLE);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputById()', function () {
test('outputs a document via string ID', function () {
$this->clearBuffer();
Json::outputById(ThrowawayDb::TABLE, 'two');
expect($this->getBufferContents())->toStartWith('{"id": "two",')->toEndWith('}');
});
test('outputs a document via numeric ID', function () {
$this->clearBuffer();
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absent')]);
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
Json::outputById(ThrowawayDb::TABLE, 18);
expect($this->getBufferContents())->toStartWith('{"id": 18,')->toEndWith('}');
});
test('outputs "{}" when a document is not found', function () {
$this->clearBuffer();
Json::outputById(ThrowawayDb::TABLE, 'seventy-five');
expect($this->getBufferContents())->toBe('{}');
});
});
describe('::outputByFields()', function () {
test('outputs matching documents', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
FieldMatch::All);
expect($this->getBufferContents())->toStartWith('[{"id": "four",')->toEndWith('}]');
});
test('outputs ordered matching documents', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], FieldMatch::All,
[Field::named('id')]);
expect_doc_order($this->getBufferContents(), ['five', 'four']);
});
test('outputs documents matching a numeric IN clause', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])]);
expect($this->getBufferContents())->toStartWith('[{"id": "three",')->toEndWith('}]');
});
test('outputs an empty array when no matching documents are found', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)]);
expect($this->getBufferContents())->toBe('[]');
});
test('outputs documents matching an inArray condition', function () {
$this->clearBuffer();
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
Json::outputByFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])]);
expect($this->getBufferContents())
->toStartWith('[')->toContain('{"id": "first",', '{"id": "second",')->toEndWith(']');
});
test('outputs an empty array when no documents match an inArray condition', function () {
$this->clearBuffer();
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
Json::outputByFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])]);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputByContains()', function () {
test('outputs matching documents', function () {
$this->clearBuffer();
Json::outputByContains(ThrowawayDb::TABLE, ['value' => 'purple']);
expect($this->getBufferContents())
->toStartWith('[')->toContain('{"id": "four",', '{"id": "five",')->toEndWith(']');
});
test('outputs ordered matching documents', function () {
$this->clearBuffer();
Json::outputByContains(ThrowawayDb::TABLE, ['sub' => ['foo' => 'green']], [Field::named('value')]);
expect_doc_order($this->getBufferContents(), ['two', 'four']);
});
test('outputs an empty array when no documents match', function () {
$this->clearBuffer();
Json::outputByContains(ThrowawayDb::TABLE, ['value' => 'indigo']);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputByJsonPath()', function () {
test('outputs matching documents', function () {
$this->clearBuffer();
Json::outputByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)');
expect($this->getBufferContents())
->toStartWith('[')->toContain('{"id": "four",', '{"id": "five",')->toEndWith(']');
});
test('outputs ordered matching documents', function () {
$this->clearBuffer();
Json::outputByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', [Field::named('id')]);
expect_doc_order($this->getBufferContents(), ['five', 'four']);
});
test('outputs an empty array when no documents match', function () {
$this->clearBuffer();
Json::outputByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)');
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputFirstByFields()', function () {
test('outputs a matching document', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')]);
expect($this->getBufferContents())->toStartWith('{"id": "two",')->toEndWith('}');
});
test('outputs a document for multiple results', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')]);
$doc = $this->getBufferContents();
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'two', 'four');
});
test('outputs a document for multiple ordered results', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')],
orderBy: [Field::named('n:num_value DESC')]);
expect($this->getBufferContents())->toStartWith('{"id": "four",')->toEndWith('}');
});
test('outputs "{}" when no documents match', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')]);
expect($this->getBufferContents())->toBe('{}');
});
});
describe('::outputFirstByContains()', function () {
test('outputs a matching document', function () {
$this->clearBuffer();
Json::outputFirstByContains(ThrowawayDb::TABLE, ['value' => 'FIRST!']);
expect($this->getBufferContents())->toStartWith('{"id": "one",')->toEndWith('}');
});
test('outputs a document for multiple results', function () {
$this->clearBuffer();
Json::outputFirstByContains(ThrowawayDb::TABLE, ['value' => 'purple']);
$doc = $this->getBufferContents();
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'four', 'five');
});
test('outputs a document for multiple ordered results', function () {
$this->clearBuffer();
Json::outputFirstByContains(ThrowawayDb::TABLE, ['value' => 'purple'], [Field::named('sub.bar NULLS FIRST')]);
expect($this->getBufferContents())->toStartWith('{"id": "five",')->toEndWith('}');
});
test('outputs "{}" when no documents match', function () {
$this->clearBuffer();
Json::outputFirstByContains(ThrowawayDb::TABLE, ['value' => 'indigo']);
expect($this->getBufferContents())->toBe('{}');
});
});
describe('::outputFirstByJsonPath()', function () {
test('outputs a matching document', function () {
$this->clearBuffer();
Json::outputFirstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ == 10)');
expect($this->getBufferContents())->toStartWith('{"id": "two",')->toEndWith('}');
});
test('outputs a document for multiple results', function () {
$this->clearBuffer();
Json::outputFirstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)');
$doc = $this->getBufferContents();
expect($doc)->toStartWith('{')->toEndWith('}');
expect_any($doc, 'four', 'five');
});
test('outputs a document for multiple ordered results', function () {
$this->clearBuffer();
Json::outputFirstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', [Field::named('id DESC')]);
expect($this->getBufferContents())->toStartWith('{"id": "four",')->toEndWith('}');
});
test('outputs "{}" when no documents match', function () {
$this->clearBuffer();
Json::outputFirstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)');
expect($this->getBufferContents())->toBe('{}');
});
});

View File

@ -63,6 +63,30 @@ describe('::array()', function () {
}); });
}); });
describe('::jsonArray()', function () {
test('returns non-empty array when data found', function () {
expect(Custom::jsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", []))
->toContain('[{', '},{', '}]');
});
test('returns empty array when no data found', function () {
expect(Custom::jsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'nothing' = '7'", []))
->toBe('[]');
});
});
describe('::outputJsonArray()', function () {
test('outputs non-empty array when data found', function () {
$this->clearBuffer();
Custom::outputJsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", []);
expect($this->getBufferContents())->toContain('[{', '},{', '}]');
});
test('outputs empty array when no data found', function () {
$this->clearBuffer();
Custom::outputJsonArray(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'nothing' = '7'", []);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::single()', function () { describe('::single()', function () {
test('returns a document when one is found', function () { test('returns a document when one is found', function () {
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'], $doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'],
@ -76,6 +100,19 @@ describe('::single()', function () {
}); });
}); });
describe('::jsonSingle()', function () {
test('returns a document when one is found', function () {
expect(Custom::jsonSingle('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'one']))
->toStartWith('{"id":"one",')->toEndWith('}');
});
test('returns no document when one is not found', function () {
expect(Custom::jsonSingle('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'eighty']))
->toBe('{}');
});
});
describe('::nonQuery()', function () { describe('::nonQuery()', function () {
test('works when documents match the WHERE clause', function () { test('works when documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []); Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);

View File

@ -0,0 +1,274 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Delete, Document, DocumentException, Field, FieldMatch, Json};
use Test\Integration\ArrayDocument;
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
/** JSON for document ID "one" */
const ONE = '{"id":"one","value":"FIRST!","num_value":0,"sub":null}';
/** JSON for document ID "two" */
const TWO = '{"id":"two","value":"another","num_value":10,"sub":{"foo":"green","bar":"blue"}}';
/** JSON for document ID "three" */
const THREE = '{"id":"three","value":"","num_value":4,"sub":null}';
/** JSON for document ID "four" */
const FOUR = '{"id":"four","value":"purple","num_value":17,"sub":{"foo":"green","bar":"red"}}';
/** JSON for document ID "five" */
const FIVE = '{"id":"five","value":"purple","num_value":18,"sub":null}';
describe('::all()', function () {
test('retrieves data', function () {
expect(Json::all(ThrowawayDb::TABLE))->toStartWith('[')->toContain(ONE, TWO, THREE, FOUR, FIVE)->toEndWith(']');
});
test('sorts data ascending', function () {
expect(Json::all(ThrowawayDb::TABLE, [Field::named('id')]))
->toBe('[' . implode(',', [FIVE, FOUR, ONE, THREE, TWO]) . ']');
});
test('sorts data descending', function () {
expect(Json::all(ThrowawayDb::TABLE, [Field::named('id DESC')]))
->toBe('[' . implode(',', [TWO, THREE, ONE, FOUR, FIVE]) . ']');
});
test('sorts data numerically', function () {
expect(Json::all(ThrowawayDb::TABLE, [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]))
->toBe('[' . implode(',', [TWO, FOUR, ONE, THREE, FIVE]) . ']');
});
test('returns an empty array when no data exists', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Json::all(ThrowawayDb::TABLE))->toBe('[]');
});
});
describe('::byId()', function () {
test('returns a document when it exists', function () {
expect(Json::byId(ThrowawayDb::TABLE, 'two'))->toBe(TWO);
});
test('returns a document with a numeric ID', function () {
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
expect(Json::byId(ThrowawayDb::TABLE, 18))->toBe('{"id":18,"value":"howdy"}');
});
test('returns "{}" when no document exists', function () {
expect(Json::byId(ThrowawayDb::TABLE, 'seventy-five'))->toBe('{}');
});
});
describe('::byFields()', function () {
test('returns matching documents', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
FieldMatch::All))
->toBe('[' . FOUR . ']');
});
test('returns ordered matching documents', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], FieldMatch::All,
[Field::named('id')]))
->toBe('[' . implode(',', [FIVE, FOUR]) . ']');
});
test('returns documents matching numeric IN clause', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])]))->toBe('[' . THREE . ']');
});
test('returns empty array when no documents match', function () {
expect(Json::byFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)]))->toBe('[]');
});
test('returns matching documents for inArray comparison', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
expect(Json::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])]))
->toBe('[{"id":"first","values":["a","b","c"]},{"id":"second","values":["c","d","e"]}]');
});
test('returns empty array when no documents match inArray comparison', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
expect(Json::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])]))->toBe('[]');
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Json::byContains('', []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Json::byJsonPath('', ''))->toThrow(DocumentException::class);
});
});
describe('::firstByFields()', function () {
test('returns a matching document', function () {
expect(Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')]))->toBe(TWO);
});
test('returns one of several matching documents', function () {
$doc = Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')]);
expect(array_any([TWO, FOUR], fn($it) => $doc == $it))
->toBeTrue("Document should have been two or four (actual $doc)");
});
test('returns first of ordered matching documents', function () {
expect(Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')],
orderBy: [Field::named('n:num_value DESC')]))
->toBe(FOUR);
});
test('returns "{}" when no documents match', function () {
expect(Json::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')]))->toBe('{}');
});
});
describe('::firstByContains()', function () {
test('throws an exception', function () {
expect(fn () => Json::firstByContains('', []))->toThrow(DocumentException::class);
});
});
describe('::firstByJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Json::firstByJsonPath('', ''))->toThrow(DocumentException::class);
});
});
describe('::outputAll()', function () {
test('outputs data', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE);
expect($this->getBufferContents())->toStartWith('[')->toContain(ONE, TWO, THREE, FOUR, FIVE)->toEndWith(']');
});
test('sorts data ascending', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('id')]);
expect($this->getBufferContents())->toBe('[' . implode(',', [FIVE, FOUR, ONE, THREE, TWO]) . ']');
});
test('sorts data descending', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('id DESC')]);
expect($this->getBufferContents())->toBe('[' . implode(',', [TWO, THREE, ONE, FOUR, FIVE]) . ']');
});
test('sorts data numerically', function () {
$this->clearBuffer();
Json::outputAll(ThrowawayDb::TABLE, [Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]);
expect($this->getBufferContents())->toBe('[' . implode(',', [TWO, FOUR, ONE, THREE, FIVE]) . ']');
});
test('outputs an empty array when no data exists', function () {
$this->clearBuffer();
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Json::outputAll(ThrowawayDb::TABLE);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputById()', function () {
test('outputs a document when it exists', function () {
$this->clearBuffer();
Json::outputById(ThrowawayDb::TABLE, 'two');
expect($this->getBufferContents())->toBe(TWO);
});
test('outputs a document with a numeric ID', function () {
$this->clearBuffer();
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
Json::outputById(ThrowawayDb::TABLE, 18);
expect($this->getBufferContents())->toBe('{"id":18,"value":"howdy"}');
});
test('outputs "{}" when no document exists', function () {
$this->clearBuffer();
Json::outputById(ThrowawayDb::TABLE, 'seventy-five');
expect($this->getBufferContents())->toBe('{}');
});
});
describe('::outputByFields()', function () {
test('outputs matching documents', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
FieldMatch::All);
expect($this->getBufferContents())->toBe('[' . FOUR . ']');
});
test('outputs ordered matching documents', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], FieldMatch::All,
[Field::named('id')]);
expect($this->getBufferContents())->toBe('[' . implode(',', [FIVE, FOUR]) . ']');
});
test('outputs documents matching numeric IN clause', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])]);
expect($this->getBufferContents())->toBe('[' . THREE . ']');
});
test('outputs empty array when no documents match', function () {
$this->clearBuffer();
Json::outputByFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)]);
expect($this->getBufferContents())->toBe('[]');
});
test('outputs matching documents for inArray comparison', function () {
$this->clearBuffer();
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
Json::outputByFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])]);
expect($this->getBufferContents())
->toBe('[{"id":"first","values":["a","b","c"]},{"id":"second","values":["c","d","e"]}]');
});
test('outputs empty array when no documents match inArray comparison', function () {
$this->clearBuffer();
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absentField')]);
foreach (ArrayDocument::testDocuments() as $doc) Document::insert(ThrowawayDb::TABLE, $doc);
Json::outputByFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])]);
expect($this->getBufferContents())->toBe('[]');
});
});
describe('::outputByContains()', function () {
test('throws an exception', function () {
expect(fn () => Json::outputByContains('', []))->toThrow(DocumentException::class);
});
});
describe('::outputByJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Json::outputByJsonPath('', ''))->toThrow(DocumentException::class);
});
});
describe('::outputFirstByFields()', function () {
test('outputs a matching document', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')]);
expect($this->getBufferContents())->toBe(TWO);
});
test('outputs one of several matching documents', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')]);
$doc = $this->getBufferContents();
expect(array_any([TWO, FOUR], fn($it) => $doc == $it))
->toBeTrue("Document should have been two or four (actual $doc)");
});
test('outputs first of ordered matching documents', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')],
orderBy: [Field::named('n:num_value DESC')]);
expect($this->getBufferContents())->toBe(FOUR);
});
test('outputs "{}" when no documents match', function () {
$this->clearBuffer();
Json::outputFirstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')]);
expect($this->getBufferContents())->toBe('{}');
});
});
describe('::outputFirstByContains()', function () {
test('throws an exception', function () {
expect(fn () => Json::outputFirstByContains('', []))->toThrow(DocumentException::class);
});
});
describe('::outputFirstByJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Json::outputFirstByJsonPath('', ''))->toThrow(DocumentException::class);
});
});

View File

@ -11,13 +11,12 @@ namespace Test\Integration;
use BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field}; use BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field};
use BitBadger\PDODocument\Mapper\ExistsMapper; use BitBadger\PDODocument\Mapper\ExistsMapper;
use PHPUnit\Framework\TestCase;
use Test\Integration\SQLite\ThrowawayDb; use Test\Integration\SQLite\ThrowawayDb;
/** /**
* Integration Test Class wrapper for SQLite integration tests * Integration Test Class wrapper for SQLite integration tests
*/ */
class SQLiteIntegrationTest extends TestCase class SQLiteIntegrationTest extends DocumentTestCase
{ {
/** @var string Database name for throwaway database */ /** @var string Database name for throwaway database */
static private string $dbName = ''; static private string $dbName = '';