Compare commits

..

No commits in common. "main" and "v1.0.0-alpha1" have entirely different histories.

138 changed files with 2686 additions and 9816 deletions

8
.gitattributes vendored
View File

@ -1,4 +1,4 @@
/.gitignore export-ignore
/.gitattributes export-ignore
/composer.lock export-ignore
/tests/**/* export-ignore
.gitignore export-ignore
.gitattributes export-ignore
composer.lock export-ignore
tests/**/* export-ignore

3
.gitignore vendored
View File

@ -1,5 +1,2 @@
.idea
.phpdoc
_site
vendor
*-tests.txt

View File

@ -1,36 +1,11 @@
# PDODocument
This library allows SQLite and PostgreSQL to be treated as document databases. It is a PHP implementation of the .NET [BitBadger.Documents](https://git.bitbadger.solutions/bit-badger/BitBadger.Documents) library.
This library allows SQLite (and, by v1.0.0-beta1, PostgreSQL) to be treated as a document database. It is a PHP implementation of the .NET [BitBadger.Documents](https://git.bitbadger.solutions/bit-badger/BitBadger.Documents) library.
## Add via Composer
[![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.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)
`composer require bit-badger/pdo-document`
For the v1 series, the `DocumentList` type's members `hasItems` and `items` are functions; in the v2 series, they are properties. Additionally, the `Option` and `Result` types included in the project have a similar difference; see the [v1 README](https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp/src/branch/v1/README.md) for PHP 8.2 or 8.3 and the [v2 README](https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp/src/branch/main/README.md) for PHP 8.4. Both versions are supported; the v1 / v2 distinction helps composer make the right choice based on the target PHP version of your project.
## Configuration
### Connection Details
The [PDO data source name](https://www.php.net/manual/en/pdo.construct.php#refsect1-pdo.construct-parameters) must be provided via `Configuration::useDSN()`. `Configuration` also has `$username`, `$password`, and `$options` variables that will be used to construct the PDO object it will use for data access.
### Document Identifiers
Each document must have a unique identifier. By default, the library assumes that this is a property or array key named `id`, but this can be controlled by setting `Configuration::$idField`. Once documents exist, this should not be changed.
IDs can be generated automatically on insert. The `AutoId` enumeration has 4 values:
- `AutoId::None` is the default; no IDs will be generated
- `AutoId::Number` will assign max-ID-plus-one to documents with an ID of 0
- `AutoId::UUID` will generate a v4 <abbr title="Universally Unique Identifier">UUID</abbr> for documents with an empty `string` ID
- `AutoId::RandomString` will generate a string of letters and numbers for documents with an empty `string` ID; `Configuration::$idStringLength` controls the length of the generated string, and defaults to 16 characters
In all generated scenarios, if the ID value is not 0 or blank, that ID will be used instead of a generated one.
`compose require bit-badger/pdo-document`
## Usage
Full documentation [is available on the project site](https://relationaldocs.bitbadger.solutions/php/).
Documentation for this library is not complete; however, its structure is very similar to the .NET version, so [its documentation will help](https://bitbadger.solutions/open-source/relational-documents/basic-usage.html) until its project specific documentation is developed. Things like `Count.All()` become `Count::all`, and all the `byField` operations are named `byFields` and take an array of fields.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,7 +1,7 @@
{
"name": "bit-badger/pdo-document",
"description": "Treat SQLite and PostgreSQL as document stores",
"keywords": ["database", "document", "sqlite", "pdo", "postgresql"],
"description": "Treat SQLite (and soon PostgreSQL) as a document store",
"keywords": ["database", "document", "sqlite", "pdo"],
"license": "MIT",
"authors": [
{
@ -14,19 +14,15 @@
"support": {
"email": "daniel@bitbadger.solutions",
"source": "https://git.bitbadger.solutions/bit-badger/pdo-document",
"rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss",
"docs": "https://relationaldocs.bitbadger.solutions/php/"
"rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss"
},
"require": {
"php": ">=8.4",
"bit-badger/inspired-by-fsharp": "^2",
"php": ">=8.3",
"netresearch/jsonmapper": "^4",
"ext-pdo": "*"
},
"require-dev": {
"square/pjson": "^0.5.0",
"phpstan/phpstan": "^1.12",
"pestphp/pest": "^3.2"
"phpunit/phpunit": "^11"
},
"autoload": {
"psr-4": {
@ -37,18 +33,9 @@
},
"autoload-dev": {
"psr-4": {
"Test\\": "./tests",
"Test\\Integration\\": "./tests/Integration",
"Test\\Integration\\PostgreSQL\\": "./tests/Integration/PostgreSQL",
"Test\\Integration\\SQLite\\": "./tests/Integration/SQLite"
}
},
"archive": {
"exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ]
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
"Test\\Unit\\": "./tests/unit",
"Test\\Integration\\": "./tests/integration",
"Test\\Integration\\SQLite\\": "./tests/integration/sqlite"
}
}
}
}

2689
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
article h2 {
border-bottom: solid 1px gray;
margin-bottom: 1rem;
}

View File

@ -1,10 +0,0 @@
export default {
defaultTheme: "auto",
iconLinks: [
{
icon: "git",
href: "https://git.bitbadger.solutions/bit-badger/pdo-document",
title: "Source Repository"
}
]
}

View File

@ -1,37 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"build": {
"content": [
{
"files": [
"index.md",
"toc.yml",
"docs/**/*.{md,yml}"
]
}
],
"resource": [
{
"files": [
"bitbadger-doc.png",
"favicon.ico"
]
}
],
"output": "_site",
"template": [
"default",
"modern",
"doc-template"
],
"globalMetadata": {
"_appName": "PDODocument",
"_appTitle": "PDODocument",
"_appLogoPath": "bitbadger-doc.png",
"_appFaviconPath": "favicon.ico",
"_appFooter": "Hand-crafted documentation created with <a href=https://dotnet.github.io/docfx target=_blank class=external>docfx</a> by <a href=https://bitbadger.solutions target=_blank class=external>Bit Badger Solutions</a>",
"_enableSearch": true,
"pdf": false
}
}
}

View File

@ -1,164 +0,0 @@
# Custom Serialization
JSON documents are sent to and received from both PostgreSQL and SQLite as `string`s; the translation to and from PHP classes uses the built-in `json_encode` and `json_decode` functions by default. These do an acceptable job, particularly if you do not care much about how the document is represented. If you want more control, though, there is a library that can help.
## Using `square/pjson` to Control Serialization
The [`square/pjson`][pjson] library provides an attribute, a trait, and a few interfaces. If these interfaces are implemented on your classes, `PDODocument` will use them instead of the standard serialization functions. This will not be an exhaustive tutorial of the library, but a few high points:
- Properties with the `#[Json]` attribute will be de/serialized, whether `private`, `protected`, or `public`; properties without an annotation will be ignored.
- `use JsonSerialize;` brings in the trait that implements pjson's behavior; this should be present in each class.
- Array properties must include the type in the attribute, so the library knows how to handle the object.
- A strongly-typed class that is represented by a single JSON value can be wired in by implementing `toJsonData` and `fromJsonData`.
- The library will not use the class's constructor to create its instances; default values in constructor-promoted properties will not be present if they are not specifically included in the document.
An example will help here; we will demonstrate all of the above.
```php
use Square\Pjson\{Json, JsonDataSerializable, JsonSerialize};
// A strongly-typed ID for our things; it is stored as a string, but wrapped in this class
class ThingId implements JsonDataSerializable
{
public string $value = '';
public function __construct(string $value = '')
{
$this->value = $value;
}
public function toJsonData(): string
{
return $this->value;
}
// $jd = JSON data
public static function fromJsonData(mixed $jd, array|string $path = []): static
{
return new static($jd);
}
}
// A thing; note the JsonSerialize trait
class Thing
{
use JsonSerialize;
#[Json]
public ThingId $id;
#[Json]
public string $name = '';
// If the property is null, it will not be part of the JSON document
#[Json(omit_empty: true)]
public ?string $notes = null;
public function __construct()
{
$this->id = new ThingId();
}
}
class BoxOfThings
{
use JsonSerialize;
// Customize the JSON key for this field
#[Json('box_number')]
public int $boxNumber = 0;
// Specify the type of this array
#[Json(type: Thing::class)]
public array $things = [];
}
```
With these declarations, the following code...
```php
$thing1 = new Thing();
$thing1->id = new ThingId('one');
$thing1->name = 'The First Thing';
$thing2 = new Thing();
$thing2->id = new ThingId('dos');
$thing2->name = 'Alternate';
$thing2->notes = 'spare';
$box = new BoxOfThings();
$box->boxNumber = 6;
$box->things = [$thing1, $thing2];
echo $box->toJsonString();
```
...will produce this JSON: _(manually pretty-printed below)_
```json
{
"box_number": 6,
"things": [
{
"id": "one",
"name": "The First Thing"
},
{
"id": "dos",
"name": "Alternate",
"notes": "spare"
}
]
}
```
Deserializing that tree, we get:
```php
$box2 = BoxOfThings::fromJsonString('...');
var_dump($box2);
```
```
object(BoxOfThings)#13 (2) {
["boxNumber"]=>
int(6)
["things"]=>
array(2) {
[0]=>
object(Thing)#25 (3) {
["id"]=>
object(ThingId)#29 (1) {
["value"]=>
string(3) "one"
}
["name"]=>
string(15) "The First Thing"
["notes"]=>
NULL
}
[1]=>
object(Thing)#28 (3) {
["id"]=>
object(ThingId)#31 (1) {
["value"]=>
string(3) "dos"
}
["name"]=>
string(9) "Alternate"
["notes"]=>
string(5) "spare"
}
}
}
```
Our round-trip was successful!
Any object passed as a document will use `square/pjson` serialization if it is defined. When passing an array as a document, if that array only has one value, and that value implements `square/pjson` serialization, it will be used; otherwise, arrays will use the standard `json_encode`/`json_decode` pairs. In practice, the ability to call `Patch::by*` with an array of fields to be updated may not give the results you would expect; using `Document::update` will replace the entire document, but it will use the `square/pjson` serialization.
## Uses for Custom Serialization
- If you exchange JSON documents with a data partner, they may have a specific format they provide or expect; this allows you to work with objects rather than trees of parsed JSON.
- If you use <abbr title="Domain Driven Design">DDD</abbr> to define custom types (similar to the `ThingId` example above), you can implement converters to translate them to/from your preferred JSON representation.
[pjson]: https://github.com/square/pjson "PJson &bull; Square &bull; GitHub"

View File

@ -1,13 +0,0 @@
# Advanced Usage
While the functions provided by the library cover lots of use cases, there are other times where applications need something else. Below are some of those.
- [Customizing Serialization][ser]
- [Related Documents and Custom Queries][rel]
- [Referential Integrity][ref] (PostgreSQL only)<br>
This page is an appendix to the conceptual part of "Relational Documents". Its example documents use PascalCase instead of camelCase, but the concept is the same.
[ser]: ./custom-serialization.md "Advanced Usage: Custom Serialization &bull; PDODocument &bull; Relational Documents"
[rel]: ./related.md "Advanced Usage: Related Documents &bull; PDODocument &bull; Bit Badger Solutions"
[ref]: /concepts/referential-integrity.html "Appendix: Referential Integrity with Documents &bull; Concepts &bull; Relational Documents"

View File

@ -1,306 +0,0 @@
# Related Documents and Custom Queries
> [!NOTE]
> This page is longer than the ideal documentation page. Understanding how to assemble custom queries requires understanding how data is stored, and the list of ways to retrieve information can be... a lot. The hope is that one reading will serve as education, and the lists of options will serve as reference lists that will assist you in crafting your queries.
## Overview
Document stores generally have fewer relationships than traditional relational databases, particularly those that arise when data is structured in [Third Normal Form][tnf]; related collections are stored in the document, and ever-increasing surrogate keys (_a la_ sequences and such) do not play well with distributed data. Unless all data is stored in a single document, though, there will still be a natural relation between documents.
Thinking back to our earlier examples, we did not store the collection of rooms in each hotel's document; each room is its own document and contains the ID of the hotel as one of its properties.
```php
class Hotel
{
string $id = '';
// ... more properties
}
class Room
{
string $id = '';
string $hotelId = '';
// ... more properties
}
```
## Document Table SQL in Depth
The library creates tables with a `data` column of type `JSONB` (PostgreSQL) or `TEXT` (SQLite), with a unique index on the configured ID name that serves as the primary key (for these examples, we'll assume it's the default `id`). The indexes created by the library all apply to the `data` column. The by-ID query for a hotel would be...
```sql
SELECT data FROM hotel WHERE data->>'id' = :id
```
...with the ID passed as the `:id` parameter.
> [!TIP]
> Using a `Query` "building block" function, `Query::whereById`, will create the `data->>'id' = :id` criteria using [the configured ID name][id].
Finding all the rooms for a hotel, using our indexes we created earlier, could use a field comparison query...
```sql
SELECT data FROM room WHERE data->>'hotelId' = :field
```
...with `:field` being "abc123"; PostgreSQL could also use a JSON containment query...
```sql
SELECT data FROM room WHERE data @> :criteria
```
...with something like `['hotelId' => 'abc123']` (serialized to JSON) passed as the matching document in the `:criteria` parameter.
So far, so good; but, if we're looking up a room, we do not want to have to make 2 queries just to also be able to display the hotel's name. The `WHERE` clause on the first query above uses the expression `data->>'id'`; this extracts a field from a JSON column as `TEXT` in PostgreSQL (or "best guess" in SQLite, but usually text). Since this is the value our unique index indexes, and we are using a relational database, we can write an efficient JOIN between these two tables.
```sql
SELECT r.data, h.data AS hotel_data
FROM room r
INNER JOIN hotel h ON h.data->>'id' = r.data->>'hotelId'
WHERE r.data->>'id' = :id
```
_(This syntax would work without the unique index; for PostgreSQL, it would default to using the GIN index (`Full` or `Optimized`), if it exists, but it wouldn't be quite as efficient as a zero-or-one unique index lookup. For SQLite, this would result in a full table scan. Both PostgreSQL and SQLite also support a `->` operator, which extracts the field as a JSON value instead of its text.)_
## Using Building Blocks
Most of the data access methods in both libraries are built up from query fragments and reusable functions; these are exposed for use in building custom queries.
### Queries
For every method or function described in [Basic Usage][], the `Query` static class or `Query` namespace contains the query for that operation.
In the `Query` class, you'll find:
- **selectFromTable** takes a table name and generates a `SELECT` statement from that name.
- **whereByFields** takes an array of field criteria and how they should be matched (`FieldMatch::Any` uses `OR`, while `FieldMatch::All` uses `AND`). `Field` has constructor functions for each `Op` it supports (`Op` is short for "operation"), and each is camelCased based on the `Op` it constructs. These functions generally take a field name and a value, but exceptions are noted below. _(Earlier versions used mostly 2-character names; these still exist for compatibility.)_
- **equal** uses `=` to create an equality comparison
- **greater** uses `>` to create a greater-than comparison
- **greaterOrEqual** uses `>=` to create a greater-than-or-equal-to comparison
- **less** uses `<` to create a less-than comparison
- **lessOrEqual** uses `<=` to create a less-than-or-equal-to comparison
- **notEqual** uses `<>` to create a not-equal comparison
- **between** uses `BETWEEN` to create a range comparison (it expects two values instead of one)
- **in** uses `IN` to create a comparison matching a set of values (it expects an array of values)
- **inArray** uses `?|` in PostgreSQL and a combination of `EXISTS / json_each / IN` in SQLite to mimic the behavior of `IN` on an array within a document (it expects the table name and an array of values)
- **exists** uses `IS NOT NULL` to create an existence comparison (requires no value)
- **notExists** uses `IS NULL` to create a non-existence comparison; fields are considered null if they are either not part of the document, or if they are part of the document but explicitly set to `null` (requires no value)
- **whereById** takes a parameter name and generates a field `Equal` comparison against the configured ID field.
- **whereDataContains** takes an optional parameter name (default is `:criteria`) and generates a JSON containment query _(PostgreSQL only)_
- **whereJsonPathMatches** takes an optional parameter name (default is `:path`) and generates a JSON Path match query _(PostgreSQL only)_
- **insert**, **save**, and **update** are the queries for those actions; all specify a `:data` parameter, and `update` also specifies an `:id` parameter against the configured ID field
Within the `PDODocument\Query` namespace, there are classes for each operation:
- **Definition** contains methods/functions to ensure tables, their keys, and field indexes exist.
- **Count**, **Exists**, **Find**, and **Delete** all require at least a table name. Their **byId** queries specify an `:id` parameter against the configured ID field (there is no `Count::byId`). Their **byFields** queries require a `Field` instance array and will use a `:field[n]` parameter if a parameter name is not provided (unless `Op::Exists` or `Op::NotExists` are used). `Count` has an `all` query which takes no further parameters and specifies no parameters.
- **Patch** and **RemoveFields** both perform partial updates. (Patching to `null` is similar, but not quite the same, as completely removing the field from the document.) Both these have the same `by*` functions as other operations.
That's a lot of reading! Some examples a bit below will help this make sense.
### Parameters
The **Parameters** class contains functions that turn values into parameters.
- **id** generates an `:id` parameter. If the ID field is an integer, it will be used as the value; otherwise, the string value of the ID will be used.
- **json** generates a user-provided-named JSON-formatted parameter for the value passed _(this can be used for PostgreSQL's JSON containment queries as well)_
- **nameFields** takes an array of `Field` criteria and generates the `:field[n]` name if it does not have a name already. This modifies the given array.
- **addFields** appends an array of `Field` criteria to the given parameter list.
- **fieldNames** creates parameters for the list of field names to be removed; for PostgreSQL, this returns a single parameter, while SQLite returns a list of parameters
In the event that no parameters are needed, pass an empty array (`[]`) in its place.
### Mapping Results
The `PDODocument\Mapper` namespace has an interface definition (`Mapper`) and several implementations of it. All mappers declare a single method, `map()`, which takes an associative array representing a database row and transforms it to its desired state.
* **DocumentMapper** deserializes the document from the given column name (default is `data`).
* **CountMapper** returns the numeric value of the first array entry.
* **ExistsMapper** returns a boolean value based on the first array entry.
* **StringMapper** returns the string value of the named field.
* **ArrayMapper** return the array as-is, with no deserialization.
We will see below how a simple custom mapper can extend or replace any of these.
## Putting It All Together
The **Custom** class has five functions:
- **list** requires a query, parameters, and a `Mapper`, and returns a `DocumentList<TDoc>` (described in an earlier section).
- **array** is the same as `::list`, except the result consumes the generator and returns the results in memory.
- **single** requires a query, parameters, and a `Mapper`, and returns one or no documents (`BitBadger\InspiredByFSharp\Option<TDoc>`).
- **scalar** requires a query, parameters, and a `Mapper`, and returns a scalar value (non-nullable; used for counts, existence, etc.)
- **nonQuery** requires a query and parameters and has no return value
> [!NOTE]
> Every other call in the library is written in terms of `Custom::list`, `Custom::scalar`, or `Custom::nonQuery`; your custom queries will use the same path the provided ones do!
Let's jump in with an example. When we query for a room, let's say that we also want to retrieve its hotel information as well. We saw the query above, but here is how we can implement it using a custom query.
```php
use PDODocument\{Configuration, Custom, Parameters, Query};
use PDODocument\Mapper\{DocumentMapper, Mapper};
// ...
// return type is Option<[Room, Hotel]>
$data = Custom::single(
"SELECT r.data AS room_data, h.data AS hotel_data
FROM room r
INNER JOIN hotel h ON h.data->>'" . Configuration::$idField . "' = r.data->>'hotelId'
WHERE r." . Query::whereById(),
[Parameters::id('my-room-key')],
new class implements Mapper {
public function map(array $result): array {
return [
(new DocumentMapper(Room::class, 'room_data'))->map($result),
(new DocumentMapper(Hotel::class, 'hotel_data'))->map($result)
];
}
});
if ($data->isSome()) {
[$room, $hotel] = $data->get();
// do stuff with the room and hotel data
}
```
This query uses `Configuration::idField` and `Query::whereById` to use the configured ID field. Creating custom queries using these building blocks allows us to utilize the configured value without hard-coding it throughout our custom queries. If the configuration changes, these queries will pick up the new field name seamlessly.
This also demonstrates a custom `Mapper`, which we can define inline as an anonymous class. It uses two different `DocumentMapper` instances to map each type, while both documents were retrieved with one query. Of course, though this example retrieved the entire document, we do not have to retrieve everything. If we only care about the name of the associated hotel, we could amend the query to retrieve only that information.
```php
use PDODocument\{Configuration, Custom, Parameters, Query};
use PDODocument\Mapper\{DocumentMapper, Mapper};
// ...
// return type is Option<[Room, string]>
$data = Custom::single(
"SELECT r.data, h.data->>'name' AS hotel_name
FROM room r
INNER JOIN hotel h ON h.data->>'" . Configuration::$idField . "' = r.data->>'hotelId'
WHERE r." . Query::whereById(),
[Parameters::id('my-room-key')],
new class implements Mapper {
public function map(array $result): array {
return [
(new DocumentMapper(Room::class, 'room_data'))->map($result),
$result['hotel_name']
];
}
});
if ($data->isSome()) {
[$room, $hotelName] = $data->get();
// do stuff with the room and hotel name
}
```
These queries are amazingly efficient, using 2 unique index lookups to return this data. Even though we do not have a foreign key between these two tables, simply being in a relational database allows us to retrieve this related data.
## Going Even Further
### Updating Data in Place
One drawback to document databases is the inability to update values in place; however, with a bit of creativity, we can do a lot more than we initially think. For a single field, SQLite has a `json_set` function that takes an existing JSON field, a field name, and a value to which it should be set. This allows us to do single-field updates in the database. If we wanted to raise our rates 10% for every room, we could use this query:
```sql
-- SQLite
UPDATE room SET data = json_set(data, 'rate', data->>'rate' * 1.1)
```
If we get any more complex, though, Common Table Expressions (CTEs) can help us. Perhaps we decided that we only wanted to raise the rates for hotels in New York, Chicago, and Los Angeles, and we wanted to exclude any brand with the word "Value" in its name. A CTE lets us select the source data we need to craft the update, then use that in the `UPDATE`'s clauses.
```sql
-- SQLite
WITH to_update AS
(SELECT r.data->>'id' AS room_id, r.data->>'rate' AS current_rate, r.data AS room_data
FROM room r
INNER JOIN hotel h ON h.data->>'id' = r.data->>'hotelId'
WHERE h.data ->> 'City' IN ('New York', 'Chicago', 'Los Angeles')
AND LOWER(h.data->>'name') NOT LIKE '%value%')
UPDATE room
SET data = json_set(to_update.room_data, 'rate', to_update.current_rate * 1.1)
WHERE room->>'id' = to_update.room_id
```
Both PostgreSQL and SQLite provide JSON patching, where multiple fields (or entire structures) can be changed at once. Let's revisit our rate increase; if we are making the rate more than $500, we'll apply a status of "Premium" to the room. If it is less than that, it should keep its same value.
First up, PostgreSQL:
```sql
-- PostgreSQL
WITH to_update AS
(SELECT r.data->>'id' AS room_id, (r.data->>'rate')::decimal AS rate, r.data->>'status' AS status
FROM room r
INNER JOIN hotel h ON h.data->>'id' = r.data->>'hotelId'
WHERE h.data->>'city' IN ('New York', 'Chicago', 'Los Angeles')
AND LOWER(h.data->>'name') NOT LIKE '%value%')
UPDATE room
SET data = data ||
('{"rate":' || to_update.rate * 1.1 || '","status":"'
|| CASE WHEN to_update.rate * 1.1 > 500 THEN 'Premium' ELSE to_update.status END
|| '"}')
WHERE room->>'id' = to_update.room_id
```
In SQLite:
```sql
-- SQLite
WITH to_update AS
(SELECT r.data->>'id' AS room_id, r.data->>'rate' AS rate, r.data->>'status' AS status
FROM room r
INNER JOIN hotel h ON h.data->>'id' = r.data->>'hotelId'
WHERE h.data->>'city' IN ('New York', 'Chicago', 'Los Angeles')
AND LOWER(h.data->>'name') NOT LIKE '%value%')
UPDATE room
SET data = json_patch(data, json(
'{"rate":' || to_update.rate * 1.1 || '","status":"'
|| CASE WHEN to_update.rate * 1.1 > 500 THEN 'Premium' ELSE to_update.status END
|| '"}'))
WHERE room->>'id' = to_update.room_id
```
For PostgreSQL, `->>` always returns text, so we need to cast the rate to a number. In either case, we do not want to use this technique for user-provided data; however, in place, it allowed us to complete all of our scenarios without having to load the documents into our application and manipulate them there.
Updates in place may not need parameters (though it would be easy to foresee a "rate adjustment" feature where the 1.1 adjustment was not hard-coded); in fact, none of the samples in this section used the document libraries at all. These queries can be executed by `Custom::nonQuery`, though, providing parameters as required.
### Using This Library for Non-Document Queries
The `Custom` functions can be used with non-document tables as well. This may be a convenient and consistent way to access your data, while delegating connection management to the library and its configured data source. The included `ArrayMapper` class will return the array from the result, and you can easily write a mapper for your classes to populate them.
Let's walk through a short example:
```php
use PDODocument\{Custom, DocumentList};
use PDODocument\Mapper\Mapper;
// Stores metadata for a given user
class MetaData
{
public string $id = '';
public string $userId = '';
public string $key = '';
public string $value = '';
// Define a static method that returns the mapper
public static function mapper(): Mapper
{
return new class implements Mapper {
public function map(array $results): MetaData
{
$it = new MetaData();
$it->id = $results['id'];
$it->userId = $results['userId'];
$it->key = $results['key'];
$it->value = $results['value'];
return $it;
}
};
}
}
// somewhere retrieving data; type is DocumentList<MetaData>
function metaDataForUser(string $userId): DocumentList
{
return Custom::list("SELECT * FROM user_metadata WHERE user_id = :userId",
[":userId" => $userId)], MetaData::mapper());
}
```
[tnf]: https://en.wikipedia.org/wiki/Third_normal_form "Third Normal Form &bull; Wikipedia"
[id]: ../getting-started.md#configuring-id-fields "Getting Started (ID Fields) &bull; PDODocument &bull; Relational Documents"
[Basic Usage]: ../basic-usage.md "Basic Usage &bull; PDODocument &bull; Relational Documents"

View File

@ -1,155 +0,0 @@
# Basic Usage
## Overview
What is a document? For the purposes of this library, documents can be objects or associative arrays. Most of the functions are geared toward classes, but arrays can be handy for patching documents.
There are several categories of operations that can be accomplished against documents.
- **Count** returns the number of documents matching some criteria
- **Exists** returns true if any documents match the given criteria
- **Insert** adds a new document, failing if the ID field is not unique
- **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
- **Patch** updates a portion of an existing document, doing nothing if no documents satisfy the 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
- **Delete** removes documents matching some criteria
`Insert` and `Save` were the only two that don't mention criteria. For the others, "some criteria" can be defined a few different ways:
- **all** references all documents in the table; applies to Count and Find
- **byId** looks for a single document on which to operate; applies to all but Count
- **byFields** uses a JSON field comparison to select documents (note that PostgreSQL will always use a text comparison, while SQLite will do its usual [best-guess on types][]); 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
Finally, `Find` and `Json` also has `firstBy*` implementations for all supported criteria types.
## Saving Documents
The library provides three different ways to save data. The first equates to a SQL `INSERT` statement, and adds a single document to the repository. If automatic IDs are enabled, the document will receive one.
```php
$room = new Room(/* ... */);
// Parameters are table name and document
Document::insert('room', $room);
```
The second is `Document::save`; and inserts the data it if does not exist and replaces the document if it does exist (what some call an "upsert"). It utilizes the `ON CONFLICT` syntax to ensure an atomic statement. Its parameters are the same as those for `Insert`. Note that this does _not_ consider automatic IDs; using this will allow you to bypass that generation for documents you know are new.
The third equates to a SQL `UPDATE` statement. `Update` applies to a full document and is usually used by ID, while `Patch` is used for partial updates and may be done by field comparison, JSON containment, or JSON Path match. For a few examples, let's begin with a query that may back the "edit hotel" page. This page lets the user update nearly all the details for the hotel, so updating the entire document would be appropriate.
```php
$hotel = Find::byId('hotel', $hotelId, Hotel::class);
if ($hotel->isDefined()) {
// update hotel properties from the posted form
Document::update('hotel', $hotel->id, $hotel);
}
```
For the next example, suppose we are upgrading our hotel, and need to take rooms 221-240 out of service*. For PostgreSQL, we can utilize a patch via JSON Path.
```php
// PostgreSQL only
Patch::byJsonPath('room',
"$ ? (@.hotelId == \"abc\" && (@.roomNumber >= 221 && @.roomNumber <= 240)",
['inService' => false]);
```
_* - we are ignoring the current reservations, end date, etc. This is very naïve example!_
For SQLite, we can utilize a `Field` query with a between operator. (This will also work in PostgreSQL.)
```php
// SQLite
Patch::byFields('room',
[Field::equal('hotelId', 'abc'), Field::between('roomNumber', 221, 240)],
['inService' => false]);
```
> [!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.
`Find` functions take a class name, and will attempt to map the document returned to the class specified. Queries which can return zero-or-one results, like `byId` and the `firstBy*` functions, will return `BitBadger\InspiredByFSharp\Option<class>` representing the possibly-single result.
All functions other than `byId` also take an optional list of fields by which the results should be ordered. There is a `Field` function `::named` to support creating a field with no comparison criteria. When specifying this name, one can include direction information like `DESC` and `NULLS FIRST`; additionally, there are two prefixes that will affect the sort order:
- `n:` will treat the field as a number. In PostgreSQL, this casts the value to a number; if a value cannot be cast, the database will return an error. For SQLite, this has no effect; it automatically guesses about types.
- `i:` will use case-insensitive ordering. In most PostgreSQL implementations, this is the default, but only due to the operating system implementation; Mac hosts are case-sensitive by default. SQLite defaults to a case-sensitive ordering. This flag will normalize this to case-insensitive sorting regardless of the host operating system or libraries.
A couple of quick examples:
```php
// Sorts "field1" ascending, using default case-sensitivity
Field::named('field1')
// Sorts "field2" descending, treating it as a number
Field::named('n:field2 DESC');
// Sorts "field3" ascending case-insensitively with NULLs last
Field::named('i:field3 NULLS LAST');
```
### Results and the `DocumentList`
`Find::all` and `Find::by*` will return a `DocumentList<class>` instance. This is a lazy iterator over these results, and has several ways to interact with the results, none of which involve loading the entire result set into memory. It is a consumable iteration; once it is read, the results are no longer available.
* **`hasItems()`** (v1) / **`hasItems`** (v2) will return `true` if there are items in the list, and will return `false` if there are no items, whether from the initial query returning 0 results or from the generator being consumed.
* **`items()`** (v1) / **`items`** (v2) returns a generator that will return each result in turn. Using it in a `foreach` loop will iterate each result; passing it into `iterator_to_array` will load the result into an in-memory array. With the `foreach` loop, only the current result is loaded into memory; it is the default way to process results.
* **`map()`** returns a generator that will map the results from the query; as it reads each result, it will transform it, returning that as the value rather than the original document. It takes a `callable` which expects a parameter of the document type in the list and returns something other than `void`.
* **`iter()`** takes a `callable` which expects a parameter of the document type in the list, and executes that function against each item as it goes. It can be used for `void` iterations, as well as iterations that may need to capture some outer state and manipulate it as the generator is iterated.
All that said, the `foreach` on `items()`/`items` is quite straightforward.
```php
// use ->items() for PHP 8.2/8.3
foreach ($result->items as $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
Functions to delete documents start with `Delete::`. Document deletion is supported by ID, JSON field comparison, JSON containment, or JSON Path match. The pattern is the same as for finding or partially updating. _(There is no library method provided to delete all documents, though deleting by JSON field comparison where a non-existent field is null would accomplish this.)_
## Counting Documents
Functions to count documents start with `Count::`. Documents may be counted by a table in its entirety, by JSON field comparison, by JSON containment, or by JSON Path match. _(Counting by ID is an existence check!)_
## Document Existence
Functions to check for existence start with `Exists::`. Documents may be checked for existence by ID, JSON field comparison, JSON containment, or JSON Path match.
## What / How Cross-Reference
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` |
|-----------------|:-----:|:------:|:----------:|:------------:|:------------:|:---------------:|:-----------------:|:-----------------:|
| `Count` | X | | X | P | P | | | |
| `Exists` | | X | X | P | P | | | |
| `Find` / `Json` | X | X | X | P | P | X | P | P |
| `Patch` | | X | X | P | P | | | |
| `RemoveFields` | | X | X | P | P | | | |
| `Delete` | | X | X | P | P | | | |
`Document::insert`, `Document::save`, and `Document::update` operate on single documents.
[best-guess on types]: https://sqlite.org/datatype3.html "Datatypes in SQLite &bull; SQLite"
[JSON Path]: https://www.postgresql.org/docs/15/functions-json.html#FUNCTIONS-SQLJSON-PATH "JSON Functions and Operators &bull; PostgreSQL Documentation"

View File

@ -1,63 +0,0 @@
# Getting Started
## Namespace
The base namespace for this library is `BitBadger\PDODocument`. There are a couple of supporting objects and enumerations, but the vast majority of its functionality is implemented as static objects and functions in this namespace.
## Configuring the Connection
As this is based on PDO, the data source names (DSNs) follow the [PostgreSQL][pgdsn] or [SQLite][sqlitedsn] standard formats. When you call `Configuration::useDSN()` to set this connection, PDODocument will also configure itself to use the SQL syntax required by the driver selected.
`Configuration` also has static properties `$username`, `$password`, and `$options`, which correspond to the other three parameters to the PDO constructor. The username and password can also be set via the `PDO_DOC_USERNAME` and `PDO_DOC_PASSWORD` environment variables - which, if they are present, override anything specified in code.
## Configuring ID Fields
Every document must have a unique identifier; by default, the property or array key `id` will be used for this purpose. To override this, set `Configuration::$idField` to the desired name.
The library can also generate IDs if they are missing. There are three different types of IDs, specified in the `AutoId` enumeration:
- `AutoId::Number` generates a "max ID plus 1" query based on the current values of the table.
- `AutoId::UUID` generates a v4 Universally Unique Identifier (UUID) for each document.
- `AutoId::RandomString` uses PHP's `random_bytes` function and converts them to lowercase hexadecimal. The length of the string defaults to 16 characters, which can be changed by setting the `Configuration::$idStringLength` property. It will only generate even counts of characters; setting this to 15 would result in a string length of 14.
## Ensuring Tables and Indexes Exist
Both PostgreSQL and SQLite store data in tables and can utilize indexes to retrieve that data efficiently. Each application will need to determine the tables and indexes it expects.
Let's use a naive example of a hotel chain to help us think through these concepts. This chain has several hotels, and each hotel has several rooms. While each hotel could have its rooms as part of a `Hotel` document, there would likely be a lot of contention when concurrent updates for rooms, so we will put rooms in their own table. The hotel will store attributes like name, address, etc.; while each room will have the hotel's ID (named `id`), along with things like room number, floor, and a list of date ranges where the room is not available. (This could be for customer reservation, maintenance, etc.)
> [!NOTE]
> All the “ensure” functions below use the `IF NOT EXISTS` clause; they are safe to run even if the table and indexes already exist.
### PostgreSQL
We have a few options when it comes to indexing our documents. We can index a specific JSON field; each table's primary key is implemented as a unique index on the configured ID field. We can also use a <abbr title="Generalized Inverted Index">GIN</abbr> index to index the entire document, and that index can even be [optimized for a subset of JSON Path operators][jsonpath].
Let's create a general-purpose index on hotels, a “HotelId” index on rooms, and an optimized document index on rooms.
```php
Definition::ensureTable('hotel');
Definition::ensureDocumentIndex('hotel', DocumentIndex::Full);
Definition::ensureTable('room');
// parameters are table name, index name, and fields to be indexed
Definition::ensureFieldIndex('room', 'hotel_id', ['hotelId']);
Definition::ensureDocumentIndex('room', DocumentIndex::Optimized);
```
### SQLite
For SQLite, the only option (outside of some quite complex techniques) for JSON indexes are indexes on fields. Just as traditional relational indexes, the order of these fields is significant. In our example, if we indexed our rooms on hotel ID and room number, it could also be used for efficient retrieval just by hotel ID.
Let's create hotel and room tables, then index rooms by hotel ID and room number.
```php
Definition::ensureTable('hotel');
Definition::ensureTable('room');
Definition::ensureFieldIndex('room', 'hotel_and_nbr', ['hotelId', 'roomNumber']);
```
Now that we have tables, let's use them!
[pgdsn]: https://www.php.net/manual/en/ref.pdo-pgsql.connection.php "PDO_PGSQL DSN &bull; Manual &bull; PHP.net"
[sqlitedsn]: https://www.php.net/manual/en/ref.pdo-sqlite.connection.php "PDO_SQLITE DSN &bull; Manual &bull; PHP.net"
[jsonpath]: https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING "JSON Indexing &bull; PostgreSQL"

View File

@ -1,11 +0,0 @@
- name: Getting Started
href: getting-started.md
- name: Basic Usage
href: basic-usage.md
- name: Advanced Usage
href: advanced/index.md
items:
- name: Custom Serialization
href: advanced/custom-serialization.md
- name: Related Documents and Custom Queries
href: advanced/related.md

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -1,41 +0,0 @@
---
_layout: landing
title: Welcome!
---
PDODocument is a PHP library that implements [relational document](/) concepts over PostgreSQL and SQLite.
## Installing
[![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.1.0) &nbsp; &nbsp; [![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)
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.
## Using
- **[Getting Started][start]** provides an overview of the library, its configuration, and ensuring that tables and any required indexes exist.
- **[Basic Usage][basic]** details document-level retrieval, persistence, and deletion.
- **[Advanced Usage][advanced]** demonstrates how to use the building blocks provided by this library to write slightly-more complex queries.
## Why Would I Choose This?
Document stores have both advantages and disadvantages as compared to relational databases. Absent a unifying standard, relational database vendors have been implementing support for this to varying degrees. The project on which this is based, `BitBadger.Documents`, [has an examination of "why", and "why not"][why], one may choose this.
## Why Not _(other database with PDO support)_?
Of the [drivers that PDO supports][pdo], PostgreSQL and SQLite have the most mature JSON support. MySQL and MariaDB are very popular choices among PHP developers, but given their divergent paths, their JSON implementations differ - and neither, as of this writing, would fit into a complete document storage model.
## Source and Feedback
`PDODocument` is an [open-source project][src]; this Gitea instance does not _(yet)_ support public registrations. To provide feedback or ask questions about this library, e-mail "daniel" at this domain, or reach out to `@daniel@fedi.summershome.org` on the Fediverse (Mastodon) or `@Bit_Badger` on Twitter.
[pkg]: https://packagist.org/packages/bit-badger/pdo-document "PDODocument &bull; Packagist"
[start]: ./docs/getting-started.md "Getting Started &bull; PDODocument &bull; Relational Documents"
[basic]: ./docs/basic-usage.md "Basic Usage &bull; PDODocument &bull; Relational Documents"
[advanced]: ./docs/advanced/index.md "Advanced Usage &bull; PDODocument &bull; Relational Documents"
[why]: /dotnet/#why-documents
[pdo]: https://www.php.net/manual/en/pdo.drivers.php "PDO Drivers &bull; Manual &bull; PHP.net"
[src]: https://git.bitbadger.solutions/bit-badger/pdo-document "PDODocument &bull; Bit Badger Solutions Git"

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor
configVersion="3"
title="PDODocument"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://www.phpdoc.org"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/phpDocumentor/phpDocumentor/master/data/xsd/phpdoc.xsd">
<paths>
<output>_site/api</output>
<cache>.phpdoc/cache</cache>
</paths>
<version number="2.1.0">
<api>
<source dsn=".">
<path>src</path>
</source>
<visibility>public</visibility>
<default-package-name>PDODocument</default-package-name>
</api>
</version>
<setting name="template.color" value="blue" />
</phpdocumentor>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./app</directory>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

View File

@ -1,58 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace BitBadger\PDODocument;
use Random\RandomException;
/**
* How automatic ID generation should be performed
*/
enum AutoId
{
/** Do not automatically generate IDs */
case None;
/** New documents with a 0 ID should receive max ID plus one */
case Number;
/** New documents with a blank ID should receive a v4 UUID (Universally Unique Identifier) */
case UUID;
/** New documents with a blank ID should receive a random string (set `Configuration::$idStringLength`) */
case RandomString;
/**
* Generate a v4 UUID
*
* @return string The v4 UUID
* @throws RandomException If an appropriate source of randomness cannot be found
*/
public static function generateUUID(): string
{
// hat tip: https://stackoverflow.com/a/15875555/276707
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); // set version to 0100
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); // set bits 6-7 to 10
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
/**
* Generate a random string ID
*
* @param int|null $length The length of string to generate (optional; defaults to configured ID string length)
* @return string A string filled with the hexadecimal representation of random bytes
* @throws RandomException If an appropriate source of randomness cannot be found
*/
public static function generateRandom(?int $length = null): string
{
return bin2hex(random_bytes(($length ?? Configuration::$idStringLength) / 2));
}
}

View File

@ -1,15 +1,7 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\InspiredByFSharp\Option;
use Exception;
use PDO;
/**
@ -20,13 +12,8 @@ class Configuration
/** @var string The name of the ID field used in the database (will be treated as the primary key) */
public static string $idField = 'id';
/** @var AutoId The automatic ID generation process to use */
public static AutoId $autoId = AutoId::None;
/**
* @var int The number of characters a string generated by `AutoId::RandomString` will have (must be an even number)
*/
public static int $idStringLength = 16;
/** @var string The data source name (DSN) of the connection string */
public static string $pdoDSN = '';
/** @var string|null The username to use to establish a data connection (use env PDO_DOC_USERNAME if possible) */
public static ?string $username = null;
@ -34,79 +21,47 @@ class Configuration
/** @var string|null The password to use to establish a data connection (use env PDO_DOC_PASSWORD if possible) */
public static ?string $password = null;
/** @var mixed[]|null Options to use for connections (driver-specific) */
/** @var array|null Options to use for connections (driver-specific) */
public static ?array $options = null;
/** @var Option<Mode> The mode in which the library is operating */
public static Option $mode;
/** @var Option<string> The data source name (DSN) of the connection string */
private static Option $pdoDSN;
/** @var Mode|null The mode in which the library is operating (filled after first connection if not configured) */
public static ?Mode $mode = null;
/** @var PDO|null The PDO instance to use for database commands */
private static ?PDO $pdo = null;
/**
* Use a Data Source Name (DSN)
*
* @param string $dsn The data source name to use (driver:[parameters])
* @throws DocumentException If a DSN does not start with `pgsql:` or `sqlite:`
*/
public static function useDSN(string $dsn): void
{
if (empty($dsn)) {
self::$mode = self::$pdoDSN = Option::None();
} else {
self::$mode = Option::Some(Mode::deriveFromDSN($dsn));
self::$pdoDSN = Option::Some($dsn);
}
}
private static ?PDO $_pdo = null;
/**
* Retrieve a new connection to the database
*
* @return PDO A new connection to the SQLite database with foreign key support enabled
* @throws Exception If this is called before a connection string is set
* @throws DocumentException If this is called before a connection string is set
*/
public static function dbConn(): PDO
{
if (is_null(self::$pdo)) {
$dsn = self::$pdoDSN->getOrThrow(fn()
=> new DocumentException('Please provide a data source name (DSN) before attempting data access'));
self::$pdo = new PDO($dsn, $_ENV['PDO_DOC_USERNAME'] ?? self::$username,
if (is_null(self::$_pdo)) {
if (empty(self::$pdoDSN)) {
throw new DocumentException('Please provide a data source name (DSN) before attempting data access');
}
self::$_pdo = new PDO(self::$pdoDSN, $_ENV['PDO_DOC_USERNAME'] ?? self::$username,
$_ENV['PDO_DOC_PASSWORD'] ?? self::$password, self::$options);
if (is_null(self::$mode)) {
$driver = self::$_pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
self::$mode = match ($driver) {
'pgsql' => Mode::PgSQL,
'sqlite' => Mode::SQLite,
default => throw new DocumentException(
"Unsupported driver $driver: this library currently supports PostgreSQL and SQLite")
};
}
}
return self::$pdo;
return self::$_pdo;
}
/**
* Retrieve the mode for the current database connection
*
* @return Mode The mode for the current database connection
* @throws Exception If the database mode has not been set
*/
public static function mode(?string $process = null): Mode
{
return self::$mode->getOrThrow(fn()
=> new DocumentException('Database mode not set' . (is_null($process) ? '' : "; cannot $process")));
}
/**
* You probably don't mean to be calling this; it is here for testing only
*
* @param Mode|null $mode The mode to set
*/
public static function overrideMode(?Mode $mode): void
{
self::$mode = Option::of($mode);
}
/**
* Clear the current PDO instance
*/
public static function resetPDO(): void
{
self::$pdo = null;
self::$_pdo = null;
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -31,42 +25,15 @@ class Count
* Count matching documents using a comparison on JSON fields
*
* @param string $tableName The name of the table in which documents should be counted
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param array|Field[] $fields The field comparison to match
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return int The count of documents matching the field comparison
* @throws DocumentException If one is encountered
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): int
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): int
{
Parameters::nameFields($fields);
return Custom::scalar(Query\Count::byFields($tableName, $fields, $match), Parameters::addFields($fields, []),
new CountMapper());
}
/**
* Count matching documents using a JSON containment query (`@>`; PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be counted
* @param mixed[]|object $criteria The criteria for the JSON containment query
* @return int The number 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): int
{
return Custom::scalar(Query\Count::byContains($tableName), Parameters::json(':criteria', $criteria),
new CountMapper());
}
/**
* Count matching documents using a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be counted
* @param string $path The JSON Path match string
* @return int The number of documents matching the given JSON Path criteria
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byJsonPath(string $tableName, string $path): int
{
return Custom::scalar(Query\Count::byJsonPath($tableName), [':path' => $path], new CountMapper());
$namedFields = Parameters::nameFields($fields);
return Custom::scalar(Query\Count::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new CountMapper());
}
}

View File

@ -1,16 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\Mapper\Mapper;
use BitBadger\PDODocument\Mapper\StringMapper;
use PDO;
use PDOException;
use PDOStatement;
@ -24,7 +16,7 @@ class Custom
* Prepare a query for execution and run it
*
* @param string $query The query to be run
* @param array<string, mixed> $parameters The parameters for the query
* @param array $parameters The parameters for the query
* @return PDOStatement The result of executing the query
* @throws DocumentException If the query execution is unsuccessful
*/
@ -46,7 +38,7 @@ class Custom
is_bool($value) => PDO::PARAM_BOOL,
is_int($value) => PDO::PARAM_INT,
is_null($value) => PDO::PARAM_NULL,
default => PDO::PARAM_STR,
default => PDO::PARAM_STR
};
$stmt->bindValue($key, $value, $dataType);
}
@ -68,7 +60,7 @@ class Custom
*
* @template TDoc The domain type of the document to retrieve
* @param string $query The query to be executed
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @param array $parameters Parameters to use in executing the query
* @param Mapper<TDoc> $mapper Mapper to deserialize the result
* @return DocumentList<TDoc> The items matching the query
* @throws DocumentException If any is encountered
@ -83,89 +75,41 @@ class Custom
*
* @template TDoc The domain type of the document to retrieve
* @param string $query The query to be executed
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @param array $parameters Parameters to use in executing the query
* @param Mapper<TDoc> $mapper Mapper to deserialize the result
* @return TDoc[] The items matching the query
* @throws DocumentException If any is encountered
*/
public static function array(string $query, array $parameters, Mapper $mapper): array
{
return iterator_to_array(self::list($query, $parameters, $mapper)->items);
return iterator_to_array(self::list($query, $parameters, $mapper)->items());
}
/**
* 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
* Execute a query that returns one or no results (returns false if not found)
*
* @template TDoc The domain type of the document to retrieve
* @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
* @param array $parameters Parameters to use in executing the query
* @param Mapper<TDoc> $mapper Mapper to deserialize the result
* @return Option<TDoc> A `Some` instance if the item is found, `None` otherwise
* @return false|TDoc The item if it is found, false if not
* @throws DocumentException If any is encountered
*/
public static function single(string $query, array $parameters, Mapper $mapper): Option
public static function single(string $query, array $parameters, Mapper $mapper): mixed
{
try {
$stmt = &self::runQuery("$query LIMIT 1", $parameters);
return ($first = $stmt->fetch(PDO::FETCH_ASSOC)) ? Option::Some($mapper->map($first)) : Option::None();
return ($first = $stmt->fetch(PDO::FETCH_ASSOC)) ? $mapper->map($first) : false;
} finally {
$stmt = null;
}
}
/**
* 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
*
* @param string $query The query to execute
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @param array $parameters Parameters to use in executing the query
* @throws DocumentException If any is encountered
*/
public static function nonQuery(string $query, array $parameters): void
@ -182,7 +126,7 @@ class Custom
*
* @template T The scalar type to return
* @param string $query The query to retrieve the value
* @param array<string, mixed> $parameters Parameters to use in executing the query
* @param array $parameters Parameters to use in executing the query
* @param Mapper<T> $mapper The mapper to obtain the result
* @return mixed|false|T The scalar value if found, false if not
* @throws DocumentException If any is encountered

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -30,23 +24,11 @@ class Definition
*
* @param string $tableName The name of the table which should be indexed
* @param string $indexName The name of the index
* @param string[] $fields Fields which should be a part of this index
* @param array $fields Fields which should be a part of this index
* @throws DocumentException If any is encountered
*/
public static function ensureFieldIndex(string $tableName, string $indexName, array $fields): void
{
Custom::nonQuery(Query\Definition::ensureIndexOn($tableName, $indexName, $fields), []);
}
/**
* Create a full-document index on a table (PostgreSQL only)
*
* @param string $tableName The name of the table on which the document index should be created
* @param DocumentIndex $indexType The type of document index to create
* @throws DocumentException If the database mode is not PostgreSQL or if an error occurs creating the index
*/
public static function ensureDocumentIndex(string $tableName, DocumentIndex $indexType): void
{
Custom::nonQuery(Query\Definition::ensureDocumentIndexOn($tableName, $indexType), []);
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -22,44 +16,21 @@ class Delete
*/
public static function byId(string $tableName, mixed $docId): void
{
Custom::nonQuery(Query\Delete::byId($tableName, $docId), Parameters::id($docId));
Custom::nonQuery(Query\Delete::byId($tableName), Parameters::id($docId));
}
/**
* Delete documents by matching a comparison on JSON fields
*
* @param string $tableName The table from which documents should be deleted
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param array|Field[] $fields The field comparison to match
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @throws DocumentException If any is encountered
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): void
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): void
{
Parameters::nameFields($fields);
Custom::nonQuery(Query\Delete::byFields($tableName, $fields, $match), Parameters::addFields($fields, []));
}
/**
* Delete documents matching a JSON containment query (`@>`; PostgreSQL only)
*
* @param string $tableName The table from which documents should be deleted
* @param mixed[]|object $criteria The JSON containment query values
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byContains(string $tableName, array|object $criteria): void
{
Custom::nonQuery(Query\Delete::byContains($tableName), Parameters::json(':criteria', $criteria));
}
/**
* Delete documents matching a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The table from which documents should be deleted
* @param string $path The JSON Path match string
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byJsonPath(string $tableName, string $path): void
{
Custom::nonQuery(Query\Delete::byJsonPath($tableName), [':path' => $path]);
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\Delete::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []));
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -17,37 +11,19 @@ class Document
* Insert a new document
*
* @param string $tableName The name of the table into which the document should be inserted
* @param mixed[]|object $document The document to be inserted
* @param array|object $document The document to be inserted
* @throws DocumentException If any is encountered
*/
public static function insert(string $tableName, array|object $document): void
{
$doInsert = fn() => Custom::nonQuery(Query::insert($tableName), Parameters::json(':data', $document));
if (Configuration::$autoId === AutoId::None) {
$doInsert();
return;
}
$id = Configuration::$idField;
$idProvided =
(is_array( $document) && is_int( $document[$id]) && $document[$id] <> 0)
|| (is_array( $document) && is_string($document[$id]) && $document[$id] <> '')
|| (is_object($document) && is_int( $document->{$id}) && $document->{$id} <> 0)
|| (is_object($document) && is_string($document->{$id}) && $document->{$id} <> '');
if ($idProvided) {
$doInsert();
} else {
Custom::nonQuery(Query::insert($tableName, Configuration::$autoId), Parameters::json(':data', $document));
}
Custom::nonQuery(Query::insert($tableName), Parameters::json(':data', $document));
}
/**
* Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert")
*
* @param string $tableName The name of the table to which the document should be saved
* @param mixed[]|object $document The document to be saved
* @param array|object $document The document to be saved
* @throws DocumentException If any is encountered
*/
public static function save(string $tableName, array|object $document): void
@ -60,7 +36,7 @@ class Document
*
* @param string $tableName The table in which the document should be updated
* @param mixed $docId The ID of the document to be updated
* @param mixed[]|object $document The document to be updated
* @param array|object $document The document to be updated
* @throws DocumentException If any is encountered
*/
public static function update(string $tableName, mixed $docId, array|object $document): void

View File

@ -1,21 +1,14 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use Exception;
use Stringable;
use Throwable;
/**
* Exceptions occurring during document processing
*/
class DocumentException extends Exception implements Stringable
class DocumentException extends Exception
{
/**
* Constructor

View File

@ -1,21 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace BitBadger\PDODocument;
/**
* The type of index to generate for the document
*/
enum DocumentIndex
{
/** A GIN index with standard operations (all operators supported) */
case Full;
/** A GIN index with JSONPath operations (optimized for @>, @?, @@ operators) */
case Optimized;
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -21,11 +15,8 @@ use PDOStatement;
*/
class DocumentList
{
/** @var TDoc|null $first The first item from the results */
private mixed $first = null;
/** @var bool $isConsumed This is set to true once the generator has been exhausted */
private bool $isConsumed = false;
/** @var TDoc|null $_first The first item from the results */
private mixed $_first = null;
/**
* Constructor
@ -35,91 +26,51 @@ class DocumentList
*/
private function __construct(private ?PDOStatement &$result, private readonly Mapper $mapper)
{
if (!is_null($this->result)) {
if ($row = $this->result->fetch(PDO::FETCH_ASSOC)) {
$this->first = $this->mapper->map($row);
} else {
$this->result = null;
}
}
}
/** @var bool True if there are items still to be retrieved from the list, false if not */
public bool $hasItems {
get => !is_null($this->result);
}
/**
* @var Generator<TDoc> The items from the document list
* @throws DocumentException If this is called once the generator has been consumed
*/
public Generator $items {
get {
if (!$this->result) {
if ($this->isConsumed) {
throw new DocumentException('Cannot call items() multiple times');
}
$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);
}
$this->isConsumed = true;
if ($row = $this->result->fetch(PDO::FETCH_ASSOC)) {
$this->_first = $this->mapper->map($row);
} else {
$this->result = null;
}
}
/**
* Map items by consuming the generator
* Construct a new document list
*
* @template U The type to which each item should be mapped
* @param callable(TDoc): U $map The mapping function
* @return Generator<U> The result of the mapping function
* @throws DocumentException If this is called once the generator has been consumed
* @param string $query The query to run to retrieve results
* @param array $parameters An associative array of parameters for the query
* @param Mapper<TDoc> $mapper A mapper to deserialize JSON documents
* @return static The document list instance
* @throws DocumentException If any is encountered
*/
public function map(callable $map): Generator
public static function create(string $query, array $parameters, Mapper $mapper): static
{
foreach ($this->items as $item) {
yield $map($item);
}
$stmt = &Custom::runQuery($query, $parameters);
return new static($stmt, $mapper);
}
/**
* Iterate the generator, running the given function for each item
* The items from the query result
*
* @param callable(TDoc): void $f The function to run for each item
* @throws DocumentException If this is called once the generator has been consumed
* @return Generator<TDoc> The items from the document list
*/
public function iter(callable $f): void
public function items(): Generator
{
foreach ($this->items as $item) {
$f($item);
if (!$this->result) return;
yield $this->_first;
while ($row = $this->result->fetch(PDO::FETCH_ASSOC)) {
yield $this->mapper->map($row);
}
$this->result = null;
}
/**
* Iterate the generator, extracting key/value pairs returned as an associative array
* Does this list have items remaining?
*
* @template TValue The type for the mapped value
* @param callable(TDoc): (int|string) $keyFunc The function to extract a key from the document
* @param callable(TDoc): TValue $valueFunc The function to extract a value from the document
* @return TValue[] An associative array of values, keyed by the extracted keys
* @throws DocumentException If this is called once the generator has been consumed
* @return bool True if there are items still to be retrieved from the list, false if not
*/
public function mapToArray(callable $keyFunc, callable $valueFunc): array
public function hasItems(): bool
{
$results = [];
foreach ($this->items as $item) {
$results[$keyFunc($item)] = $valueFunc($item);
}
return $results;
return !is_null($this->result);
}
/**
@ -129,19 +80,4 @@ class DocumentList
{
if (!is_null($this->result)) $this->result = null;
}
/**
* Construct a new document list
*
* @param string $query The query to run to retrieve results
* @param array<string, mixed> $parameters An associative array of parameters for the query
* @param Mapper<TDoc> $mapper A mapper to deserialize JSON documents
* @return self<TDoc> The document list instance
* @throws DocumentException If any is encountered
*/
public static function create(string $query, array $parameters, Mapper $mapper): self
{
$stmt = &Custom::runQuery($query, $parameters);
return new self($stmt, $mapper);
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -25,7 +19,7 @@ class Exists
*/
public static function byId(string $tableName, mixed $docId): bool
{
return Custom::scalar(Query\Exists::byId($tableName, $docId), Parameters::id($docId), new ExistsMapper());
return Custom::scalar(Query\Exists::byId($tableName), Parameters::id($docId), new ExistsMapper());
}
/**
@ -33,41 +27,14 @@ class Exists
*
* @param string $tableName The name of the table in which document existence should be determined
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return bool True if any documents match the field comparison, false if not
* @throws DocumentException If any is encountered
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): bool
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): bool
{
Parameters::nameFields($fields);
return Custom::scalar(Query\Exists::byFields($tableName, $fields, $match), Parameters::addFields($fields, []),
new ExistsMapper());
}
/**
* Determine if documents exist by a JSON containment query (`@>`; PostgreSQL only)
*
* @param string $tableName The name of the table in which document existence should be determined
* @param mixed[]|object $criteria The criteria for the JSON containment query
* @return bool True if any documents match the JSON containment query, false if not
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byContains(string $tableName, array|object $criteria): bool
{
return Custom::scalar(Query\Exists::byContains($tableName), Parameters::json(':criteria', $criteria),
new ExistsMapper());
}
/**
* Determine if documents exist by a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The name of the table in which document existence should be determined
* @param string $path The JSON Path match string
* @return bool True if any documents match the JSON Path string, false if not
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byJsonPath(string $tableName, string $path): bool
{
return Custom::scalar(Query\Exists::byJsonPath($tableName), [':path' => $path], new ExistsMapper());
$namedFields = Parameters::nameFields($fields);
return Custom::scalar(Query\Exists::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new ExistsMapper());
}
}

View File

@ -1,15 +1,7 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use Exception;
/**
* Criteria for a field WHERE clause
*/
@ -27,110 +19,57 @@ class Field
* @param string $paramName The name of the parameter to which this should be bound
* @param string $qualifier A table qualifier for the `data` column
*/
public function __construct(public string $fieldName = '', public Op $op = Op::Equal, public mixed $value = '',
public function __construct(public string $fieldName = '', public Op $op = Op::EQ, public mixed $value = '',
public string $paramName = '', public string $qualifier = '') { }
/**
* Append the parameter name and value to the given associative array
*
* @param array<string, mixed> $existing The existing parameters
* @return array<string, mixed> The given parameter array with this field's name and value(s) appended
* @param array $existing The existing parameters
* @return array The given parameter array with this field's name and value appended
*/
public function appendParameter(array $existing): array
{
switch ($this->op) {
case Op::Exists:
case Op::NotExists:
case Op::EX:
case Op::NEX:
break;
case Op::Between:
case Op::BT:
$existing["{$this->paramName}min"] = $this->value[0];
$existing["{$this->paramName}max"] = $this->value[1];
break;
case Op::In:
for ($idx = 0; $idx < count($this->value); $idx++) {
$existing["{$this->paramName}_$idx"] = $this->value[$idx];
}
break;
case Op::InArray:
$mkString = Configuration::mode("Append parameters for InArray condition") === Mode::PgSQL;
$values = $this->value['values'];
for ($idx = 0; $idx < count($values); $idx++) {
$existing["{$this->paramName}_$idx"] = $mkString ? "$values[$idx]" : $values[$idx];
}
break;
default:
$existing[$this->paramName] = $this->value;
}
return $existing;
}
/**
* Derive the path for this field
*
* @param bool $asJSON Whether the field should be treated as JSON in the query (optional, default false)
* @return string The path for this field
* @throws Exception If the database mode has not been set
*/
public function path(bool $asJSON = false): string
{
$extra = $asJSON ? '' : '>';
if (str_contains($this->fieldName, '.')) {
$mode = Configuration::mode('determine field path');
if ($mode === Mode::PgSQL) {
return "data#>$extra'{" . implode(',', explode('.', $this->fieldName)) . "}'";
}
if ($mode === Mode::SQLite) {
$parts = explode('.', $this->fieldName);
$last = array_pop($parts);
return "data->'" . implode("'->'", $parts) . "'->$extra'$last'";
}
}
return "data->$extra'$this->fieldName'";
}
/**
* Get the WHERE clause fragment for this parameter
*
* @return string The WHERE clause fragment for this parameter
* @throws Exception|DocumentException If the database mode has not been set
* @throws DocumentException If the database mode has not been set
*/
public function toWhere(): string
{
$mode = Configuration::mode('make field WHERE clause');
$fieldName = (empty($this->qualifier) ? '' : "$this->qualifier.") . $this->path($this->op === Op::InArray);
$fieldPath = match ($mode) {
Mode::PgSQL => match (true) {
$this->op === Op::Between,
$this->op === Op::In => is_numeric($this->value[0]) ? "($fieldName)::numeric" : $fieldName,
is_numeric($this->value) => "($fieldName)::numeric",
default => $fieldName,
},
default => $fieldName,
};
$criteria = match ($this->op) {
Op::Exists,
Op::NotExists => '',
Op::Between => " {$this->paramName}min AND {$this->paramName}max",
Op::In => ' (' . $this->inParameterNames() . ')',
Op::InArray => $mode === Mode::PgSQL ? ' ARRAY[' . $this->inParameterNames() . ']' : '',
default => " $this->paramName",
Op::EX, Op::NEX => '',
Op::BT => " {$this->paramName}min AND {$this->paramName}max",
default => " $this->paramName"
};
return $mode === Mode::SQLite && $this->op === Op::InArray
? "EXISTS (SELECT 1 FROM json_each({$this->value['table']}.data, '\$.$this->fieldName') WHERE value IN ("
. $this->inParameterNames() . '))'
: $fieldPath . ' ' . $this->op->toSQL() . $criteria;
}
/**
* Create parameter names for an IN clause
*
* @return string A comma-delimited string of parameter names
*/
private function inParameterNames(): string
{
$values = $this->op === Op::In ? $this->value : $this->value['values'];
return implode(', ',
array_map(fn($value, $key) => "{$this->paramName}_$key", $values, range(0, count($values) - 1)));
$prefix = $this->qualifier == '' ? '' : "$this->qualifier.";
$fieldPath = match (Configuration::$mode) {
Mode::SQLite => "{$prefix}data->>'"
. (str_contains($this->fieldName, '.')
? implode("'->>'", explode('.', $this->fieldName))
: $this->fieldName)
. "'",
Mode::PgSQL => $this->op == Op::BT && is_numeric($this->value[0])
? "({$prefix}data->>'$this->fieldName')::numeric"
: "{$prefix}data->>'$this->fieldName'",
default => throw new DocumentException('Database mode not set; cannot make field WHERE clause')
};
return $fieldPath . ' ' . $this->op->toString() . $criteria;
}
/**
@ -139,24 +78,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for which equality will be checked
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function equal(string $fieldName, mixed $value, string $paramName = ''): self
public static function EQ(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::Equal, $value, $paramName);
}
/**
* Create an equals (=) field criterion _(alias for `Field.equal`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for which equality will be checked
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function EQ(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::equal($fieldName, $value, $paramName);
return new static($fieldName, Op::EQ, $value, $paramName);
}
/**
@ -165,24 +91,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the greater than comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function greater(string $fieldName, mixed $value, string $paramName = ''): self
public static function GT(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::Greater, $value, $paramName);
}
/**
* Create a greater than (>) field criterion _(alias for `Field.greater`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the greater than comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function GT(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::greater($fieldName, $value, $paramName);
return new static($fieldName, Op::GT, $value, $paramName);
}
/**
@ -191,24 +104,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the greater than or equal to comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function greaterOrEqual(string $fieldName, mixed $value, string $paramName = ''): self
public static function GE(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::GreaterOrEqual, $value, $paramName);
}
/**
* Create a greater than or equal to (>=) field criterion _(alias for `Field.greaterOrEqual`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the greater than or equal to comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function GE(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::greaterOrEqual($fieldName, $value, $paramName);
return new static($fieldName, Op::GE, $value, $paramName);
}
/**
@ -217,24 +117,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the less than comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function less(string $fieldName, mixed $value, string $paramName = ''): self
public static function LT(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::Less, $value, $paramName);
}
/**
* Create a less than (<) field criterion _(alias for `Field.less`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the less than comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function LT(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::less($fieldName, $value, $paramName);
return new static($fieldName, Op::LT, $value, $paramName);
}
/**
@ -243,24 +130,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the less than or equal to comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function lessOrEqual(string $fieldName, mixed $value, string $paramName = ''): self
public static function LE(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::LessOrEqual, $value, $paramName);
}
/**
* Create a less than or equal to (<=) field criterion _(alias for `Field.lessOrEqual`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the less than or equal to comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function LE(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::lessOrEqual($fieldName, $value, $paramName);
return new static($fieldName, Op::LE, $value, $paramName);
}
/**
@ -269,24 +143,11 @@ class Field
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the not equals comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function notEqual(string $fieldName, mixed $value, string $paramName = ''): self
public static function NE(string $fieldName, mixed $value, string $paramName = ''): static
{
return new self($fieldName, Op::NotEqual, $value, $paramName);
}
/**
* Create a not equals (<>) field criterion _(alias for `Field.notEqual`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $value The value for the not equals comparison
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function NE(string $fieldName, mixed $value, string $paramName = ''): self
{
return self::notEqual($fieldName, $value, $paramName);
return new static($fieldName, Op::NE, $value, $paramName);
}
/**
@ -296,109 +157,32 @@ class Field
* @param mixed $minValue The lower value for range
* @param mixed $maxValue The upper value for the range
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function between(string $fieldName, mixed $minValue, mixed $maxValue, string $paramName = ''): self
public static function BT(string $fieldName, mixed $minValue, mixed $maxValue, string $paramName = ''): static
{
return new self($fieldName, Op::Between, [$minValue, $maxValue], $paramName);
}
/**
* Create a BETWEEN field criterion _(alias for `Field.between`)_
*
* @param string $fieldName The name of the field against which the value will be compared
* @param mixed $minValue The lower value for range
* @param mixed $maxValue The upper value for the range
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function BT(string $fieldName, mixed $minValue, mixed $maxValue, string $paramName = ''): self
{
return self::between($fieldName, $minValue, $maxValue, $paramName);
}
/**
* Create an IN field criterion
*
* @param string $fieldName The name of the field against which the values will be compared
* @param mixed[] $values The potential matching values for the field
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function in(string $fieldName, array $values, string $paramName = ''): self
{
return new self($fieldName, Op::In, $values, $paramName);
}
/**
* Create an IN ARRAY field criterion
*
* @param string $fieldName The name of the field against which the values will be compared
* @param string $tableName The table name where this field is located
* @param mixed[] $values The potential matching values for the field
* @param string $paramName The name of the parameter to which this should be bound (optional; generated if blank)
* @return self The field with the requested criterion
*/
public static function inArray(string $fieldName, string $tableName, array $values, string $paramName = ''): self
{
return new self($fieldName, Op::InArray, ['table' => $tableName, 'values' => $values], $paramName);
return new static($fieldName, Op::BT, [$minValue, $maxValue], $paramName);
}
/**
* Create an exists (IS NOT NULL) field criterion
*
* @param string $fieldName The name of the field for which existence will be checked
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function exists(string $fieldName): self
public static function EX(string $fieldName): static
{
return new self($fieldName, Op::Exists, '', '');
}
/**
* Create an exists (IS NOT NULL) field criterion _(alias for `Field.exists`)_
*
* @param string $fieldName The name of the field for which existence will be checked
* @return self The field with the requested criterion
*/
public static function EX(string $fieldName): self
{
return self::exists($fieldName);
return new static($fieldName, Op::EX, '', '');
}
/**
* Create a not exists (IS NULL) field criterion
*
* @param string $fieldName The name of the field for which non-existence will be checked
* @return self The field with the requested criterion
* @return static The field with the requested criterion
*/
public static function notExists(string $fieldName): self
public static function NEX(string $fieldName): static
{
return new self($fieldName, Op::NotExists, '', '');
}
/**
* Create a not exists (IS NULL) field criterion _(alias for `Field.notExists`)_
*
* @param string $fieldName The name of the field for which non-existence will be checked
* @return self The field with the requested criterion
*/
public static function NEX(string $fieldName): self
{
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, '', '');
return new static($fieldName, Op::NEX, '', '');
}
}

View File

@ -1,34 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace BitBadger\PDODocument;
/**
* How multiple fields should be matched
*/
enum FieldMatch
{
/** Match all provided fields (`AND`) */
case All;
/** Match any provided fields (`OR`) */
case Any;
/**
* Get the SQL keyword for this enumeration value
*
* @return string The SQL keyword for this enumeration value
*/
public function toSQL(): string
{
return match ($this) {
FieldMatch::All => 'AND',
FieldMatch::Any => 'OR',
};
}
}

View File

@ -1,18 +1,11 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\Mapper\DocumentMapper;
/**
* Functions to retrieve documents as domain objects
* Functions to find documents
*/
class Find
{
@ -22,14 +15,12 @@ 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<TDoc> $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<TDoc> A list of all documents from the table
* @throws DocumentException If any is encountered
*/
public static function all(string $tableName, string $className, array $orderBy = []): DocumentList
public static function all(string $tableName, string $className): DocumentList
{
return Custom::list(Query::selectFromTable($tableName) . Query::orderBy($orderBy), [],
new DocumentMapper($className));
return Custom::list(Query::selectFromTable($tableName), [], new DocumentMapper($className));
}
/**
@ -39,13 +30,12 @@ class Find
* @param string $tableName The table from which the document should be retrieved
* @param mixed $docId The ID of the document to retrieve
* @param class-string<TDoc> $className The name of the class to be retrieved
* @return Option<TDoc> A `Some` instance if the document is found, `None` otherwise
* @return false|TDoc The document if it exists, false if not
* @throws DocumentException If any is encountered
*/
public static function byId(string $tableName, mixed $docId, string $className): Option
public static function byId(string $tableName, mixed $docId, string $className): mixed
{
return Custom::single(Query\Find::byId($tableName, $docId), Parameters::id($docId),
new DocumentMapper($className));
return Custom::single(Query\Find::byId($tableName), Parameters::id($docId), new DocumentMapper($className));
}
/**
@ -53,55 +43,18 @@ class Find
*
* @template TDoc The type of document to be retrieved
* @param string $tableName The table from which documents should be retrieved
* @param Field[] $fields The field comparison to match
* @param array|Field[] $fields The field comparison to match
* @param class-string<TDoc> $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)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return DocumentList<TDoc> 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, array $orderBy = []): DocumentList
string $conjunction = 'AND'): DocumentList
{
Parameters::nameFields($fields);
return Custom::list(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy),
Parameters::addFields($fields, []), new DocumentMapper($className));
}
/**
* Retrieve documents via a JSON containment query (`@>`; PostgreSQL only)
*
* @template TDoc The type of document to be retrieved
* @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<TDoc> $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<TDoc> 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,
array $orderBy = []): DocumentList
{
return Custom::list(Query\Find::byContains($tableName) . Query::orderBy($orderBy),
Parameters::json(':criteria', $criteria), new DocumentMapper($className));
}
/**
* Retrieve documents via a JSON Path match query (`@?`; PostgreSQL only)
*
* @template TDoc The type of document to be retrieved
* @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<TDoc> $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<TDoc> 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,
array $orderBy = []): DocumentList
{
return Custom::list(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path],
new DocumentMapper($className));
$namedFields = Parameters::nameFields($fields);
return Custom::list(Query\Find::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new DocumentMapper($className));
}
/**
@ -109,54 +62,17 @@ class Find
*
* @template TDoc The type of document to be retrieved
* @param string $tableName The table from which the document should be retrieved
* @param Field[] $fields The field comparison to match
* @param array|Field[] $fields The field comparison to match
* @param class-string<TDoc> $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<TDoc> A `Some` instance with the first document if any matches are found, `None` otherwise
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return false|TDoc The first document if any matches are found, false otherwise
* @throws DocumentException If any is encountered
*/
public static function firstByFields(string $tableName, array $fields, string $className,
?FieldMatch $match = null, array $orderBy = []): Option
string $conjunction = 'AND'): mixed
{
Parameters::nameFields($fields);
return Custom::single(Query\Find::byFields($tableName, $fields, $match) . Query::orderBy($orderBy),
Parameters::addFields($fields, []), new DocumentMapper($className));
}
/**
* Retrieve documents via a JSON containment query (`@>`), returning only the first result (PostgreSQL only)
*
* @template TDoc The type of document to be retrieved
* @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<TDoc> $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<TDoc> 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,
array $orderBy = []): Option
{
return Custom::single(Query\Find::byContains($tableName) . Query::orderBy($orderBy),
Parameters::json(':criteria', $criteria), new DocumentMapper($className));
}
/**
* Retrieve documents via a JSON Path match query (`@?`), returning only the first result (PostgreSQL only)
*
* @template TDoc The type of document to be retrieved
* @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<TDoc> $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<TDoc> 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,
array $orderBy = []): Option
{
return Custom::single(Query\Find::byJsonPath($tableName) . Query::orderBy($orderBy), [':path' => $path],
new DocumentMapper($className));
$namedFields = Parameters::nameFields($fields);
return Custom::single(Query\Find::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new DocumentMapper($className));
}
}

View File

@ -1,252 +0,0 @@
<?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');
}
}

View File

@ -1,23 +1,14 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
/**
* A mapper that returns the associative array from the database
*
* @implements Mapper<array<string|int, mixed>>
*/
class ArrayMapper implements Mapper
{
/**
* @inheritDoc
* @return array<string|int, mixed> The array given as the parameter
*/
public function map(array $result): array
{

View File

@ -1,21 +1,15 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
/**
* A mapper that returns the integer value of the first item in the results
*
* @implements Mapper<int>
*/
class CountMapper implements Mapper
{
/** @inheritDoc */
/**
* @inheritDoc
*/
public function map(array $result): int
{
return (int) $result[0];

View File

@ -1,16 +1,10 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
use BitBadger\PDODocument\DocumentException;
use Exception;
use JsonMapper;
use JsonMapper_Exception;
/**
* Map domain class instances from JSON documents
@ -26,27 +20,24 @@ class DocumentMapper implements Mapper
* @param class-string<TDoc> $className The type of class to be returned by this mapping
* @param string $fieldName The name of the field (optional; defaults to `data`)
*/
public function __construct(public string $className, public string $fieldName = 'data') {}
public function __construct(public string $className, public string $fieldName = 'data') { }
/**
* Map a result to a domain class instance
*
* @param array<string|int, mixed> $result An associative array representing a single database result
* @param array $result An associative array representing a single database result
* @return TDoc The document, deserialized from its JSON representation
* @throws DocumentException If the JSON cannot be deserialized
*/
public function map(array $result): mixed
{
try {
if (method_exists($this->className, 'fromJsonString')) {
return $this->className::fromJsonString($result[$this->fieldName]);
}
$json = json_decode($result[$this->fieldName]);
if (is_null($json)) {
throw new DocumentException("Could not map document for $this->className: " . json_last_error_msg());
}
return (new JsonMapper())->map($json, $this->className);
} catch (Exception $ex) {
} catch (JsonMapper_Exception $ex) {
throw new DocumentException("Could not map document for $this->className", previous: $ex);
}
}

View File

@ -1,32 +1,24 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
use BitBadger\PDODocument\{Configuration, Mode};
use Exception;
use BitBadger\PDODocument\{Configuration, DocumentException, Mode};
/**
* Map an EXISTS result to a boolean value
*
* @implements Mapper<bool>
*/
class ExistsMapper implements Mapper
{
/**
* @inheritDoc
* @throws Exception If the database mode has not been set
* @throws DocumentException If the database mode has not been set
*/
public function map(array $result): bool
{
return match (Configuration::mode('map existence result')) {
return match (Configuration::$mode) {
Mode::PgSQL => (bool)$result[0],
Mode::SQLite => (int)$result[0] > 0,
default => throw new DocumentException('Database mode not set; cannot map existence result'),
};
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
@ -18,7 +12,7 @@ interface Mapper
/**
* Map a result to the specified type
*
* @param array<string|int, mixed> $result An associative array representing a single database result
* @param array $result An associative array representing a single database result
* @return T The item mapped from the given result
*/
public function map(array $result): mixed;

View File

@ -1,15 +1,9 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
/**
* Map a string result from the named field
* Map a string result from the
*
* @implements Mapper<string>
*/
@ -30,7 +24,7 @@ class StringMapper implements Mapper
return match (false) {
key_exists($this->fieldName, $result) => null,
is_string($result[$this->fieldName]) => "{$result[$this->fieldName]}",
default => $result[$this->fieldName],
default => $result[$this->fieldName]
};
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -18,19 +12,4 @@ enum Mode
/** Storing documents in a SQLite database */
case SQLite;
/**
* Derive the mode from the Data Source Name (DSN)
*
* @return Mode The database mode based on the DSN
* @throws DocumentException If the DSN does not start with `pgsql:` or `sqlite:`
*/
public static function deriveFromDSN(string $dsn): Mode
{
return match (true) {
str_starts_with($dsn, 'pgsql:') => Mode::PgSQL,
str_starts_with($dsn, 'sqlite:') => Mode::SQLite,
default => throw new DocumentException('This library currently supports PostgreSQL and SQLite'),
};
}
}

View File

@ -1,60 +1,48 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
/**
* The types of comparison operators allowed for JSON fields
* The types of logical operations allowed for JSON fields
*/
enum Op
{
/** Equals (=) */
case Equal;
case EQ;
/** Greater Than (>) */
case Greater;
case GT;
/** Greater Than or Equal To (>=) */
case GreaterOrEqual;
case GE;
/** Less Than (<) */
case Less;
case LT;
/** Less Than or Equal To (<=) */
case LessOrEqual;
case LE;
/** Not Equal to (<>) */
case NotEqual;
case NE;
/** Between (BETWEEN) */
case Between;
/** In (IN) */
case In;
/** In Array (PostgreSQL - ?|, SQLite - EXISTS / json_each / IN) */
case InArray;
case BT;
/** Exists (IS NOT NULL) */
case Exists;
case EX;
/** Does Not Exist (IS NULL) */
case NotExists;
case NEX;
/**
* Get the SQL representation of this operator
* Get the string representation of this operator
*
* @return string The operator to use in SQL statements
*/
public function toSQL(): string
public function toString(): string
{
return match ($this) {
Op::Equal => "=",
Op::Greater => ">",
Op::GreaterOrEqual => ">=",
Op::Less => "<",
Op::LessOrEqual => "<=",
Op::NotEqual => "<>",
Op::Between => "BETWEEN",
Op::In => "IN",
Op::InArray => "??|", // The actual operator is ?|, but needs to be escaped by doubling
Op::Exists => "IS NOT NULL",
Op::NotExists => "IS NULL",
Op::EQ => "=",
Op::GT => ">",
Op::GE => ">=",
Op::LT => "<",
Op::LE => "<=",
Op::NE => "<>",
Op::BT => "BETWEEN",
Op::EX => "IS NOT NULL",
Op::NEX => "IS NULL"
};
}
}

View File

@ -1,15 +1,7 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use Exception;
/**
* Functions to create parameters for queries
*/
@ -19,61 +11,45 @@ class Parameters
* Create an ID parameter (name ":id", key will be treated as a string)
*
* @param mixed $key The key representing the ID of the document
* @return array<string, mixed> An associative array with an "@id" parameter/value pair
* @return array|string[] An associative array with an "@id" parameter/value pair
*/
public static function id(mixed $key): array
{
return [':id' => ((is_int($key) || is_string($key)) ? $key : "$key")];
return [':id' => is_int($key) || is_string($key) ? $key : "$key"];
}
/**
* Create a parameter with a JSON value
*
* @param string $name The name of the JSON parameter
* @param mixed[]|object $document The value that should be passed as a JSON string
* @return array<string, string> An associative array with the named parameter/value pair
* @param object|array $document The value that should be passed as a JSON string
* @return array An associative array with the named parameter/value pair
*/
public static function json(string $name, object|array $document): array
{
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if (is_object($document)) {
return [
$name => method_exists($document, 'toJson') ? $document->toJson($flags) : json_encode($document, $flags)
];
}
$key = array_key_first($document);
if (is_array($document[$key])) {
if (empty($document[$key])) return [$name => json_encode($document, $flags)];
if (method_exists($document[$key][array_key_first($document[$key])], 'toJson')) {
return [
$name => sprintf('{%s:[%s]}', json_encode($key, $flags),
implode(',', array_map(fn($it) => $it->toJson($flags), $document[$key])))
];
}
}
return [$name => json_encode($document, $flags)];
return [$name => json_encode($document, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
}
/**
* Fill in parameter names for any fields missing one
*
* @param Field[] $fields The fields for the query (entries with no names will be modified)
* @param array|Field[] $fields The fields for the query
* @return array|Field[] The fields, all with non-blank parameter names
*/
public static function nameFields(array &$fields): void
public static function nameFields(array $fields): array
{
array_walk($fields, function (Field $field, int $idx) {
if (empty($field->paramName)) $field->paramName =":field$idx";
});
for ($idx = 0; $idx < sizeof($fields); $idx++) {
if ($fields[$idx]->paramName == '') $fields[$idx]->paramName = ":field$idx";
}
return $fields;
}
/**
* Add field parameters to the given set of parameters
*
* @param Field[] $fields The fields being compared in the query
* @param array<string, mixed> $parameters An associative array of parameters to which the fields should be added
* @return array<string, mixed> An associative array of parameter names and values with the fields added
* @param array|Field[] $fields The fields being compared in the query
* @param array $parameters An associative array of parameters to which the fields should be added
* @return array An associative array of parameter names and values with the fields added
*/
public static function addFields(array $fields, array $parameters): array
{
@ -84,18 +60,22 @@ class Parameters
* Create JSON field name parameters for the given field names to the given parameter
*
* @param string $paramName The name of the parameter for the field names
* @param string[] $fieldNames The names of the fields for the parameter
* @return array<string, string> An associative array of parameter/value pairs for the field names
* @throws Exception If the database mode has not been set
* @param array|string[] $fieldNames The names of the fields for the parameter
* @return array An associative array of parameter/value pairs for the field names
* @throws DocumentException If the database mode has not been set
*/
public static function fieldNames(string $paramName, array $fieldNames): array
{
$mode = Configuration::mode('generate field name parameters');
return match ($mode) {
Mode::PgSQL => [$paramName => "{" . implode(",", $fieldNames) . "}"],
Mode::SQLite => array_combine(array_map(fn($idx) => $paramName . $idx,
empty($fieldNames) ? [] : range(0, sizeof($fieldNames) - 1)),
array_map(fn($field) => "$.$field", $fieldNames))
};
switch (Configuration::$mode) {
case Mode::PgSQL:
return [$paramName => "ARRAY['" . implode("','", $fieldNames) . "']"];
case Mode::SQLite:
$it = [];
$idx = 0;
foreach ($fieldNames as $field) $it[$paramName . $idx++] = "$.$field";
return $it;
default:
throw new DocumentException('Database mode not set; cannot generate field name parameters');
}
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -18,12 +12,12 @@ class Patch
*
* @param string $tableName The table in which the document should be patched
* @param mixed $docId The ID of the document to be patched
* @param mixed[]|object $patch The object with which the document should be patched (will be JSON-encoded)
* @param array|object $patch The object with which the document should be patched (will be JSON-encoded)
* @throws DocumentException If any is encountered (database mode must be set)
*/
public static function byId(string $tableName, mixed $docId, array|object $patch): void
{
Custom::nonQuery(Query\Patch::byId($tableName, $docId),
Custom::nonQuery(Query\Patch::byId($tableName),
array_merge(Parameters::id($docId), Parameters::json(':data', $patch)));
}
@ -31,44 +25,16 @@ class Patch
* Patch documents using a comparison on JSON fields
*
* @param string $tableName The table in which documents should be patched
* @param Field[] $fields The field comparison to match
* @param mixed[]|object $patch The object with which the documents should be patched (will be JSON-encoded)
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param array|Field[] $fields The field comparison to match
* @param array|object $patch The object with which the documents should be patched (will be JSON-encoded)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @throws DocumentException If any is encountered
*/
public static function byFields(string $tableName, array $fields, array|object $patch,
?FieldMatch $match = null): void
string $conjunction = 'AND'): void
{
Parameters::nameFields($fields);
Custom::nonQuery(Query\Patch::byFields($tableName, $fields, $match),
Parameters::addFields($fields, Parameters::json(':data', $patch)));
}
/**
* Patch documents using a JSON containment query (`@>`; PostgreSQL only)
*
* @param string $tableName The table in which documents should be patched
* @param mixed[]|object $criteria The JSON containment query values to match
* @param mixed[]|object $patch The object with which the documents should be patched (will be JSON-encoded)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byContains(string $tableName, array|object $criteria, array|object $patch): void
{
Custom::nonQuery(Query\Patch::byContains($tableName),
array_merge(Parameters::json(':criteria', $criteria), Parameters::json(':data', $patch)));
}
/**
* Patch documents using a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The table in which documents should be patched
* @param string $path The JSON Path match string
* @param mixed[]|object $patch The object with which the documents should be patched (will be JSON-encoded)
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byJsonPath(string $tableName, string $path, array|object $patch): void
{
Custom::nonQuery(Query\Patch::byJsonPath($tableName),
array_merge([':path' => $path], Parameters::json(':data', $patch)));
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\Patch::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, Parameters::json(':data', $patch)));
}
}

View File

@ -1,16 +1,7 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use Exception;
use Random\RandomException;
/**
* Query construction functions
*/
@ -31,92 +22,34 @@ class Query
* Create a WHERE clause fragment to implement a comparison on fields in a JSON document
*
* @param Field[] $fields The field comparison to generate
* @param FieldMatch|null $match How to join multiple conditions (optional; defaults to All)
* @param string $conjunction How to join multiple conditions (optional; defaults to AND)
* @return string The WHERE clause fragment matching the given fields and parameter
* @throws DocumentException If the database mode has not been set
*/
public static function whereByFields(array $fields, ?FieldMatch $match = null): string
public static function whereByFields(array $fields, string $conjunction = 'AND'): string
{
return implode(' ' . ($match ?? FieldMatch::All)->toSQL() . ' ', array_map(fn($it) => $it->toWhere(), $fields));
return implode(" $conjunction ", array_map(fn($it) => $it->toWhere(), $fields));
}
/**
* Create a WHERE clause fragment to implement an ID-based query
*
* @param string $paramName The parameter name where the value of the ID will be provided (optional; default @id)
* @param mixed $docId The ID of the document to be retrieved; used to determine type for potential JSON field
* casts (optional; string ID assumed if no value is provided)
* @return string The WHERE clause fragment to match by ID
* @throws DocumentException If the database mode has not been set
*/
public static function whereById(string $paramName = ':id', mixed $docId = null): string
public static function whereById(string $paramName = ':id'): string
{
return self::whereByFields([Field::equal(Configuration::$idField, $docId ?? '', $paramName)]);
return self::whereByFields([Field::EQ(Configuration::$idField, 0, $paramName)]);
}
/**
* Create a WHERE clause fragment to implement a JSON containment query (PostgreSQL only)
* Query to insert a document
*
* @param string $paramName The name of the parameter (optional; defaults to `:criteria`)
* @return string The WHERE clause fragment for a JSON containment query
* @throws Exception|DocumentException If the database mode is not PostgreSQL
* @param string $tableName The name of the table into which a document should be inserted
* @return string The INSERT statement for the given table
*/
public static function whereDataContains(string $paramName = ':criteria'): string
public static function insert(string $tableName): string
{
if (Configuration::mode() <> Mode::PgSQL) {
throw new DocumentException('JSON containment is only supported on PostgreSQL');
}
return "data @> $paramName";
}
/**
* Create a WHERE clause fragment to implement a JSON Path match query (PostgreSQL only)
*
* @param string $paramName The name of the parameter (optional; defaults to `:path`)
* @return string The WHERE clause fragment for a JSON Path match query
* @throws Exception|DocumentException If the database mode is not PostgreSQL
*/
public static function whereJsonPathMatches(string $paramName = ':path'): string
{
if (Configuration::mode() <> Mode::PgSQL) {
throw new DocumentException('JSON Path matching is only supported on PostgreSQL');
}
return "jsonb_path_exists(data, $paramName::jsonpath)";
}
/**
* Create an `INSERT` statement for a document
*
* @param string $tableName The name of the table into which the document will be inserted
* @param AutoId|null $autoId The version of automatic ID query to generate (optional, defaults to None)
* @return string The `INSERT` statement to insert a document
* @throws Exception|DocumentException If the database mode is not set
*/
public static function insert(string $tableName, ?AutoId $autoId = null): string
{
try {
$id = Configuration::$idField;
$values = match (Configuration::mode('generate auto-ID INSERT statement')) {
Mode::SQLite => match ($autoId ?? AutoId::None) {
AutoId::None => ':data',
AutoId::Number => "json_set(:data, '$.$id', "
. "(SELECT coalesce(max(data->>'$id'), 0) + 1 FROM $tableName))",
AutoId::UUID => "json_set(:data, '$.$id', '" . AutoId::generateUUID() . "')",
AutoId::RandomString => "json_set(:data, '$.$id', '" . AutoId::generateRandom() ."')",
},
Mode::PgSQL => match ($autoId ?? AutoId::None) {
AutoId::None => ':data',
AutoId::Number => ":data::jsonb || ('{\"$id\":' || "
. "(SELECT COALESCE(MAX((data->>'$id')::numeric), 0) + 1 "
. "FROM $tableName) || '}')::jsonb",
AutoId::UUID => ":data::jsonb || '{\"$id\":\"" . AutoId::generateUUID() . "\"}'",
AutoId::RandomString => ":data::jsonb || '{\"$id\":\"" . AutoId::generateRandom() . "\"}'",
}
};
return "INSERT INTO $tableName VALUES ($values)";
} catch (RandomException $ex) {
throw new DocumentException('Unable to generate ID: ' . $ex->getMessage(), previous: $ex);
}
return "INSERT INTO $tableName VALUES (:data)";
}
/**
@ -127,8 +60,8 @@ class Query
*/
public static function save(string $tableName): string
{
$id = Configuration::$idField;
return "INSERT INTO $tableName VALUES (:data) ON CONFLICT ((data->>'$id')) DO UPDATE SET data = EXCLUDED.data";
return self::insert($tableName)
. " ON CONFLICT ((data->>'" . Configuration::$idField . "')) DO UPDATE SET data = EXCLUDED.data";
}
/**
@ -136,60 +69,9 @@ class Query
*
* @param string $tableName The name of the table in which the document should be updated
* @return string The UPDATE query for the document
* @throws DocumentException If the database mode has not been set
*/
public static function update(string $tableName): string
{
return "UPDATE $tableName SET data = :data WHERE " . self::whereById();
}
/**
* Transform a field to an ORDER BY clause segment
*
* @param Field $field The field by which ordering should be implemented
* @return string The ORDER BY fragment for the given field
* @throws Exception If the database mode has not been set
*/
private static function mapToOrderBy(Field $field): string
{
$mode = Configuration::mode('render ORDER BY clause');
if (str_contains($field->fieldName, ' ')) {
$parts = explode(' ', $field->fieldName);
$field->fieldName = array_shift($parts);
$direction = ' ' . implode(' ', $parts);
} else {
$direction = '';
}
if (str_starts_with($field->fieldName, 'n:')) {
$field->fieldName = substr($field->fieldName, 2);
$value = match ($mode) {
Mode::PgSQL => '(' . $field->path() . ')::numeric',
Mode::SQLite => $field->path()
};
} elseif (str_starts_with($field->fieldName, 'i:')) {
$field->fieldName = substr($field->fieldName, 2);
$value = match ($mode) {
Mode::PgSQL => 'LOWER(' . $field->path() . ')',
Mode::SQLite => $field->path() . ' COLLATE NOCASE'
};
} else {
$value = $field->path();
}
return (empty($field->qualifier) ? '' : "$field->qualifier.") . $value . $direction;
}
/**
* 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
{
return empty($fields) ? "" : ' ORDER BY ' . implode(', ', array_map(self::mapToOrderBy(...), $fields));
}
}

View File

@ -1,14 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{DocumentException, Field, FieldMatch, Query};
use BitBadger\PDODocument\{Field, Query};
/**
* Queries for counting documents
@ -31,36 +25,11 @@ class Count
*
* @param string $tableName The name of the table in which documents should be counted
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to join multiple conditions (optional; defaults to All)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The query to count documents using a field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): string
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): string
{
return self::all($tableName) . ' WHERE ' . Query::whereByFields($fields, $match);
}
/**
* Query to count matching documents using a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be counted
* @return string The query to count documents using a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName): string
{
return self::all($tableName) . ' WHERE ' . Query::whereDataContains();
}
/**
* Query to count matching documents using a JSON Path match (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be counted
* @return string The query to count documents using a JSON Path match
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byJsonPath(string $tableName): string
{
return self::all($tableName) . ' WHERE ' . Query::whereJsonPathMatches();
return self::all($tableName) . ' WHERE ' . Query::whereByFields($fields, $conjunction);
}
}

View File

@ -1,15 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{Configuration, DocumentException, DocumentIndex, Mode};
use Exception;
use BitBadger\PDODocument\{Configuration, DocumentException, Mode};
/**
* Queries to define tables and indexes
@ -21,13 +14,14 @@ class Definition
*
* @param string $name The name of the table (including schema, if applicable)
* @return string The CREATE TABLE statement for the document table
* @throws Exception If the database mode has not been set
* @throws DocumentException If the database mode has not been set
*/
public static function ensureTable(string $name): string
{
$dataType = match (Configuration::mode('make create table statement')) {
$dataType = match (Configuration::$mode) {
Mode::PgSQL => 'JSONB',
Mode::SQLite => 'TEXT',
default => throw new DocumentException('Database mode not set; cannot make create table statement')
};
return "CREATE TABLE IF NOT EXISTS $name (data $dataType NOT NULL)";
}
@ -41,7 +35,7 @@ class Definition
private static function splitSchemaAndTable(string $tableName): array
{
$parts = explode('.', $tableName);
return sizeof($parts) === 1 ? ["", $tableName] : [$parts[0], $parts[1]];
return sizeof($parts) == 1 ? ["", $tableName] : [$parts[0], $parts[1]];
}
/**
@ -49,7 +43,7 @@ class Definition
*
* @param string $tableName The name of the table which should be indexed
* @param string $indexName The name of the index to create
* @param string[] $fields An array of fields to be indexed; may contain direction (ex. 'salary DESC')
* @param array $fields An array of fields to be indexed; may contain direction (ex. 'salary DESC')
* @return string The CREATE INDEX statement to ensure the index exists
*/
public static function ensureIndexOn(string $tableName, string $indexName, array $fields): string
@ -57,7 +51,7 @@ class Definition
[, $tbl] = self::splitSchemaAndTable($tableName);
$jsonFields = implode(', ', array_map(function (string $field) {
$parts = explode(' ', $field);
$fieldName = sizeof($parts) === 1 ? $field : $parts[0];
$fieldName = sizeof($parts) == 1 ? $field : $parts[0];
$direction = sizeof($parts) < 2 ? "" : " $parts[1]";
return "(data->>'$fieldName')$direction";
}, $fields));
@ -74,25 +68,4 @@ class Definition
{
return str_replace('INDEX', 'UNIQUE INDEX', self::ensureIndexOn($tableName, 'key', [Configuration::$idField]));
}
/**
* Create a document-wide index on a table (PostgreSQL only)
*
* @param string $tableName The name of the table on which the document index should be created
* @param DocumentIndex $indexType The type of index to be created
* @return string The SQL statement to create an index on JSON documents in the specified table
* @throws Exception|DocumentException If the database mode is not PostgreSQL
*/
public static function ensureDocumentIndexOn(string $tableName, DocumentIndex $indexType): string
{
if (Configuration::mode() <> Mode::PgSQL) {
throw new DocumentException('Document indexes are only supported on PostgreSQL');
}
[, $tbl] = self::splitSchemaAndTable($tableName);
$extraOps = match ($indexType) {
DocumentIndex::Full => '',
DocumentIndex::Optimized => ' jsonb_path_ops',
};
return "CREATE INDEX IF NOT EXISTS idx_{$tbl}_document ON $tableName USING GIN (data$extraOps)";
}
}

View File

@ -1,14 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{DocumentException, Field, FieldMatch, Query};
use BitBadger\PDODocument\{Field, Query};
/**
* Queries to delete documents
@ -19,13 +13,11 @@ class Delete
* Query to delete a document by its ID
*
* @param string $tableName The name of the table from which a document should be deleted
* @param mixed $docId The ID of the document to be deleted (optional; string ID assumed)
* @return string The DELETE statement to delete a document by its ID
* @throws DocumentException If the database mode has not been set
*/
public static function byId(string $tableName, mixed $docId = null): string
public static function byId(string $tableName): string
{
return "DELETE FROM $tableName WHERE " . Query::whereById(docId: $docId);
return "DELETE FROM $tableName WHERE " . Query::whereById();
}
/**
@ -33,36 +25,11 @@ class Delete
*
* @param string $tableName The name of the table from which documents should be deleted
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The DELETE statement to delete documents via field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): string
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): string
{
return "DELETE FROM $tableName WHERE " . Query::whereByFields($fields, $match);
}
/**
* Query to delete documents using a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be deleted
* @return string The DELETE statement to delete documents via a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName): string
{
return "DELETE FROM $tableName WHERE " . Query::whereDataContains();
}
/**
* Query to delete documents using a JSON Path match query (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be deleted
* @return string The DELETE statement to delete documents via a JSON Path match
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byJsonPath(string $tableName): string
{
return "DELETE FROM $tableName WHERE " . Query::whereJsonPathMatches();
return "DELETE FROM $tableName WHERE " . Query::whereByFields($fields, $conjunction);
}
}

View File

@ -1,14 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{DocumentException, Field, FieldMatch, Query};
use BitBadger\PDODocument\{Field, Query};
/**
* Queries to determine document existence
@ -31,13 +25,11 @@ class Exists
* Query to determine if a document exists for the given ID
*
* @param string $tableName The name of the table in which document existence should be checked
* @param mixed $docId The ID of the document whose existence should be checked (optional; string ID assumed)
* @return string The query to determine document existence by ID
* @throws DocumentException If the database mode has not been set
*/
public static function byId(string $tableName, mixed $docId = null): string
public static function byId(string $tableName): string
{
return self::query($tableName, Query::whereById(docId: $docId));
return self::query($tableName, Query::whereById());
}
/**
@ -45,36 +37,11 @@ class Exists
*
* @param string $tableName The name of the table in which document existence should be checked
* @param Field[] $fields The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The query to determine document existence by field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): string
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): string
{
return self::query($tableName, Query::whereByFields($fields, $match));
}
/**
* Query to determine if documents exist using a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table in which document existence should be checked
* @return string The query to determine document existence by a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName): string
{
return self::query($tableName, Query::whereDataContains());
}
/**
* Query to determine if documents exist using a JSON Path match query (PostgreSQL only)
*
* @param string $tableName The name of the table in which document existence should be checked
* @return string The query to determine document existence by a JSON Path match
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byJsonPath(string $tableName): string
{
return self::query($tableName, Query::whereJsonPathMatches());
return self::query($tableName, Query::whereByFields($fields, $conjunction));
}
}

View File

@ -1,14 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{DocumentException, Field, FieldMatch, Query};
use BitBadger\PDODocument\{Field, Query};
/**
* Queries for retrieving documents
@ -19,13 +13,11 @@ class Find
* Query to retrieve a document by its ID
*
* @param string $tableName The name of the table from which a document should be retrieved
* @param mixed $docId The ID of the document to be retrieved (optional; string ID assumed)
* @return string The SELECT statement to retrieve a document by its ID
* @throws DocumentException If the database mode has not been set
*/
public static function byId(string $tableName, mixed $docId = null): string
public static function byId(string $tableName): string
{
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereById(docId: $docId);
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereById();
}
/**
@ -33,36 +25,11 @@ class Find
*
* @param string $tableName The name of 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 string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The SELECT statement to retrieve documents by field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $fields, ?FieldMatch $match = null): string
public static function byFields(string $tableName, array $fields, string $conjunction = 'AND'): string
{
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereByFields($fields, $match);
}
/**
* Query to retrieve documents using a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @return string The SELECT statement to retrieve documents by a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName): string
{
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereDataContains();
}
/**
* Query to retrieve documents using a JSON Path match query (PostgreSQL only)
*
* @param string $tableName The name of the table from which documents should be retrieved
* @return string The SELECT statement to retrieve documents by a JSON Path match
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byJsonPath(string $tableName): string
{
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereJsonPathMatches();
return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereByFields($fields, $conjunction);
}
}

View File

@ -1,15 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{Configuration, DocumentException, Field, FieldMatch, Mode, Query};
use Exception;
use BitBadger\PDODocument\{Configuration, DocumentException, Field, Mode, Query};
/**
* Queries to perform partial updates on documents
@ -22,13 +15,14 @@ class Patch
* @param string $tableName The name of the table in which documents should be patched
* @param string $whereClause The body of the WHERE clause to use in the UPDATE statement
* @return string The UPDATE statement to perform the patch
* @throws Exception If the database mode has not been set
* @throws DocumentException If the database mode has not been set
*/
public static function update(string $tableName, string $whereClause): string
{
$setValue = match (Configuration::mode('make patch statement')) {
$setValue = match (Configuration::$mode) {
Mode::PgSQL => 'data || :data',
Mode::SQLite => 'json_patch(data, json(:data))',
default => throw new DocumentException('Database mode not set; cannot make patch statement')
};
return "UPDATE $tableName SET data = $setValue WHERE $whereClause";
}
@ -37,13 +31,12 @@ class Patch
* Query to patch (partially update) a document by its ID
*
* @param string $tableName The name of the table in which a document should be patched
* @param mixed $docId The ID of the document to be patched (optional; string ID assumed)
* @return string The query to patch a document by its ID
* @throws DocumentException If the database mode has not been set
*/
public static function byId(string $tableName, mixed $docId = null): string
public static function byId(string $tableName): string
{
return self::update($tableName, Query::whereById(docId: $docId));
return self::update($tableName, Query::whereById());
}
/**
@ -51,36 +44,12 @@ class Patch
*
* @param string $tableName The name of the table in which documents should be patched
* @param array|Field[] $field The field comparison to match
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The query to patch documents via field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $field, ?FieldMatch $match = null): string
public static function byFields(string $tableName, array $field, string $conjunction = 'AND'): string
{
return self::update($tableName, Query::whereByFields($field, $match));
}
/**
* Query to patch (partially update) a document via a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be patched
* @return string The query to patch documents via a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName): string
{
return self::update($tableName, Query::whereDataContains());
}
/**
* Query to patch (partially update) a document via a JSON Path match query (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be patched
* @return string The query to patch documents via a JSON Path match
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byJsonPath(string $tableName): string
{
return self::update($tableName, Query::whereJsonPathMatches());
return self::update($tableName, Query::whereByFields($field, $conjunction));
}
}

View File

@ -1,15 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\{Configuration, DocumentException, Field, FieldMatch, Mode, Query};
use Exception;
use BitBadger\PDODocument\{Configuration, DocumentException, Field, Mode, Query};
/**
* Queries to remove fields from documents
@ -24,74 +17,50 @@ class RemoveFields
* Create an UPDATE statement to remove fields from a JSON document
*
* @param string $tableName The name of the table in which documents should be manipulated
* @param array<string, mixed> $parameters The parameter list for the query
* @param array $parameters The parameter list for the query
* @param string $whereClause The body of the WHERE clause for the update
* @return string The UPDATE statement to remove fields from a JSON document
* @throws Exception If the database mode has not been set
* @throws DocumentException If the database mode has not been set
*/
public static function update(string $tableName, array $parameters, string $whereClause): string
{
return match (Configuration::mode('generate field removal query')) {
Mode::PgSQL => "UPDATE $tableName SET data = data - " . array_keys($parameters)[0]
. "::text[] WHERE $whereClause",
Mode::SQLite => "UPDATE $tableName SET data = json_remove(data, " . implode(', ', array_keys($parameters))
. ") WHERE $whereClause",
};
switch (Configuration::$mode) {
case Mode::PgSQL:
return "UPDATE $tableName SET data = data - " . array_keys($parameters)[0] . " WHERE $whereClause";
case Mode::SQLite:
$paramNames = implode(', ', array_keys($parameters));
return "UPDATE $tableName SET data = json_remove(data, $paramNames) WHERE $whereClause";
default:
throw new DocumentException('Database mode not set; cannot generate field removal query');
}
}
/**
* Query to remove fields from a document by the document's ID
*
* @param string $tableName The name of the table in which the document should be manipulated
* @param array<string, mixed> $parameters The parameter list for the query
* @param mixed $docId The ID of the document from which fields should be removed (optional; string ID assumed)
* @param array $parameters The parameter list for the query
* @return string The UPDATE statement to remove fields from a document by its ID
* @throws DocumentException If the database mode has not been set
*/
public static function byId(string $tableName, array $parameters, mixed $docId = null): string
public static function byId(string $tableName, array $parameters): string
{
return self::update($tableName, $parameters, Query::whereById(docId: $docId));
return self::update($tableName, $parameters, Query::whereById());
}
/**
* Query to remove fields from documents via a comparison on JSON fields within the document
*
* @param string $tableName The name of the table in which documents should be manipulated
* @param Field[] $fields The field comparison to match
* @param array<string, mixed> $parameters The parameter list for the query
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param array|Field[] $fields The field comparison to match
* @param array $parameters The parameter list for the query
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @return string The UPDATE statement to remove fields from documents via field comparison
* @throws DocumentException If the database mode has not been set
*/
public static function byFields(string $tableName, array $fields, array $parameters,
?FieldMatch $match = null): string
string $conjunction = 'AND'): string
{
return self::update($tableName, $parameters, Query::whereByFields($fields, $match));
}
/**
* Query to remove fields from documents via a JSON containment query (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be manipulated
* @param array<string, mixed> $parameters The parameter list for the query
* @return string The UPDATE statement to remove fields from documents via a JSON containment query
* @throws DocumentException If the database mode is not PostgreSQL
*/
public static function byContains(string $tableName, array $parameters): string
{
return self::update($tableName, $parameters, Query::whereDataContains());
}
/**
* Query to remove fields from documents via a JSON Path match query (PostgreSQL only)
*
* @param string $tableName The name of the table in which documents should be manipulated
* @param array<string, mixed> $parameters The parameter list for the query
* @return string The UPDATE statement to remove fields from documents via a JSON Path match
* @throws DocumentException
*/
public static function byJsonPath(string $tableName, array $parameters): string
{
return self::update($tableName, $parameters, Query::whereJsonPathMatches());
return self::update($tableName, $parameters, Query::whereByFields($fields, $conjunction));
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
@ -18,13 +12,13 @@ class RemoveFields
*
* @param string $tableName The table in which the document should have fields removed
* @param mixed $docId The ID of the document from which fields should be removed
* @param string[] $fieldNames The names of the fields to be removed
* @param array|string[] $fieldNames The names of the fields to be removed
* @throws DocumentException If any is encountered
*/
public static function byId(string $tableName, mixed $docId, array $fieldNames): void
{
$nameParams = Parameters::fieldNames(':name', $fieldNames);
Custom::nonQuery(Query\RemoveFields::byId($tableName, $nameParams, $docId),
Custom::nonQuery(Query\RemoveFields::byId($tableName, $nameParams),
array_merge(Parameters::id($docId), $nameParams));
}
@ -32,47 +26,17 @@ class RemoveFields
* Remove fields from documents via a comparison on a JSON field in the document
*
* @param string $tableName The table in which documents should have fields removed
* @param Field[] $fields The field comparison to match
* @param string[] $fieldNames The names of the fields to be removed
* @param FieldMatch|null $match How to handle multiple conditions (optional; defaults to All)
* @param array|Field[] $fields The field comparison to match
* @param array|string[] $fieldNames The names of the fields to be removed
* @param string $conjunction How to handle multiple conditions (optional; defaults to `AND`)
* @throws DocumentException If any is encountered
*/
public static function byFields(string $tableName, array $fields, array $fieldNames,
?FieldMatch $match = null): void
string $conjunction = 'AND'): void
{
$nameParams = Parameters::fieldNames(':name', $fieldNames);
Parameters::nameFields($fields);
Custom::nonQuery(Query\RemoveFields::byFields($tableName, $fields, $nameParams, $match),
Parameters::addFields($fields, $nameParams));
}
/**
* Remove fields from documents via a JSON containment query (`@>`; PostgreSQL only)
*
* @param string $tableName The table in which documents should have fields removed
* @param mixed[]|object $criteria The JSON containment query values
* @param string[] $fieldNames The names of the fields to be removed
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byContains(string $tableName, array|object $criteria, array $fieldNames): void
{
$nameParams = Parameters::fieldNames(':name', $fieldNames);
Custom::nonQuery(Query\RemoveFields::byContains($tableName, $nameParams),
array_merge(Parameters::json(':criteria', $criteria), $nameParams));
}
/**
* Remove fields from documents via a JSON Path match query (`@?`; PostgreSQL only)
*
* @param string $tableName The table in which documents should have fields removed
* @param string $path The JSON Path match string
* @param string[] $fieldNames The names of the fields to be removed
* @throws DocumentException If the database mode is not PostgreSQL, or if an error occurs
*/
public static function byJsonPath(string $tableName, string $path, array $fieldNames): void
{
$nameParams = Parameters::fieldNames(':name', $fieldNames);
Custom::nonQuery(Query\RemoveFields::byJsonPath($tableName, $nameParams),
array_merge([':path' => $path], $nameParams));
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\RemoveFields::byFields($tableName, $namedFields, $nameParams, $conjunction),
Parameters::addFields($namedFields, $nameParams));
}
}

View File

@ -1,23 +0,0 @@
#!/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

@ -1,35 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test\Integration;
/**
* A document with an array of values
*/
class ArrayDocument
{
/**
* @param string $id The ID of the document
* @param string[] $values The values for the document
*/
public function __construct(public string $id = '', public array $values = []) { }
/**
* A set of documents used for integration tests
*
* @return ArrayDocument[] Test documents for InArray tests
*/
public static function testDocuments(): array
{
return [
new ArrayDocument('first', ['a', 'b', 'c']),
new ArrayDocument('second', ['c', 'd', 'e']),
new ArrayDocument('third', ['x', 'y', 'z'])
];
}
}

View File

@ -1,38 +0,0 @@
<?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

@ -1,17 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test\Integration;
/**
* A test document with a numeric ID
*/
class NumDocument
{
public function __construct(public int $id = 0, public string $value = '') { }
}

View File

@ -1,59 +0,0 @@
<?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 BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field};
use BitBadger\PDODocument\Mapper\ExistsMapper;
use Test\Integration\PostgreSQL\ThrowawayDb;
/**
* Integration Test Class wrapper for PostgreSQL integration tests
*/
class PgIntegrationTest extends DocumentTestCase
{
/** @var string Database name for throwaway database */
static private string $dbName = '';
public static function setUpBeforeClass(): void
{
self::$dbName = ThrowawayDb::create(false);
}
protected function setUp(): void
{
parent::setUp();
ThrowawayDb::loadData();
}
protected function tearDown(): void
{
Delete::byFields(ThrowawayDb::TABLE, [ Field::exists(Configuration::$idField)]);
parent::tearDown();
}
public static function tearDownAfterClass(): void
{
ThrowawayDb::destroy(self::$dbName);
self::$dbName = '';
}
/**
* Does the given named object exist in the database?
*
* @param string $name The name of the object whose existence should be verified
* @return bool True if the object exists, false if not
* @throws DocumentException If any is encountered
*/
protected function dbObjectExists(string $name): bool
{
return Custom::scalar('SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = :name)',
[':name' => $name], new ExistsMapper());
}
}

View File

@ -1,45 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Field};
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
describe('::all()', function () {
test('counts all documents', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byFields()', function () {
test('counts for numeric range correctly', function () {
expect(Count::byFields(ThrowawayDb::TABLE, [Field::between('num_value', 10, 20)]))->toBe(3);
});
test('counts for non-numeric range correctly', function () {
expect(Count::byFields(ThrowawayDb::TABLE, [Field::between('value', 'aardvark', 'apple')]))->toBe(1);
});
});
describe('::byContains()', function () {
test('counts matching documents', function () {
expect(Count::byContains(ThrowawayDb::TABLE, ['value' => 'purple']))->toBe(2);
});
test('returns 0 for no matching documents', function () {
expect(Count::byContains(ThrowawayDb::TABLE, ['value' => 'magenta']))->toBe(0);
});
});
describe('::byJsonPath()', function () {
test('counts matching documents', function () {
expect(Count::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ < 5)'))->toBe(2);
});
test('returns 0 for no matching documents', function () {
expect(Count::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)'))->toBe(0);
});
});

View File

@ -1,134 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Custom, DocumentException, Query};
use BitBadger\PDODocument\Mapper\{CountMapper, DocumentMapper};
use Test\Integration\PostgreSQL\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'postgresql');
describe('::runQuery()', function () {
test('runs a valid query successfully', function () {
$stmt = &Custom::runQuery('SELECT data FROM ' . ThrowawayDb::TABLE . ' LIMIT 1', []);
try {
expect($stmt)->not->toBeNull();
} finally {
$stmt = null;
}
});
test('fails with an invalid query', function () {
$stmt = null;
try {
expect(function () use (&$stmt) { $stmt = &Custom::runQuery('GRAB stuff FROM over_there UNTIL done', []); })
->toThrow(DocumentException::class);
} finally {
$stmt = null;
}
});
});
describe('::list()', function () {
test('returns non-empty list when data found', function () {
$list = Custom::list(Query::selectFromTable(ThrowawayDb::TABLE), [], new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$count = 0;
foreach ($list->items as $ignored) $count++;
expect($count)->toBe(5);
});
test('returns empty list when no data found', function () {
expect(Custom::list(
Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE (data->>'num_value')::numeric > :value",
[':value' => 100], new DocumentMapper(TestDocument::class)))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::array()', function () {
test('returns non-empty array when data found', function () {
expect(Custom::array(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", [],
new DocumentMapper(TestDocument::class)))
->not->toBeNull()
->toHaveCount(2);
});
test('returns empty array when no data found', function () {
expect(Custom::array(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'value' = :value",
[':value' => 'not there'], new DocumentMapper(TestDocument::class)))
->not->toBeNull()
->toBeEmpty();
});
});
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 () {
test('returns a document when one is found', function () {
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'],
new DocumentMapper(TestDocument::class));
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('one');
});
test('returns no document when one is not found', function () {
expect(Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'eighty'], new DocumentMapper(TestDocument::class)))
->isNone->toBeTrue();
});
});
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 () {
test('works when documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Count::all(ThrowawayDb::TABLE))->toBe(0);
});
test('works when no documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE . " WHERE (data->>'num_value')::numeric > :value",
[':value' => 100]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::scalar()', function () {
test('returns a scalar value', function () {
expect(Custom::scalar("SELECT 5 AS it", [], new CountMapper()))->toBe(5);
});
});

View File

@ -1,47 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Definition, DocumentIndex};
pest()->group('integration', 'postgresql');
describe('::ensureTable()', function () {
test('creates a table', function () {
expect($this->dbObjectExists('ensured'))->toBeFalse()
->and($this->dbObjectExists('idx_ensured_key'))->toBeFalse();
Definition::ensureTable('ensured');
expect($this->dbObjectExists('ensured'))->toBeTrue()
->and($this->dbObjectExists('idx_ensured_key'))->toBeTrue();
});
});
describe('::ensureFieldIndex()', function () {
test('creates an index', function () {
expect($this->dbObjectExists('idx_ensured_test'))->toBeFalse();
Definition::ensureTable('ensured');
Definition::ensureFieldIndex('ensured', 'test', ['name', 'age']);
expect($this->dbObjectExists('idx_ensured_test'))->toBeTrue();
});
});
describe('::ensureDocumentIndex()', function () {
test('creates a full index', function () {
$docIdx = 'idx_doc_table_document';
Definition::ensureTable('doc_table');
expect($this->dbObjectExists($docIdx))->toBeFalse();
Definition::ensureDocumentIndex('doc_table', DocumentIndex::Full);
expect($this->dbObjectExists($docIdx))->toBeTrue();
});
test('creates an optimized index', function () {
$docIdx = 'idx_doc_tbl_document';
Definition::ensureTable('doc_tbl');
expect($this->dbObjectExists($docIdx))->toBeFalse();
Definition::ensureDocumentIndex('doc_tbl', DocumentIndex::Optimized);
expect($this->dbObjectExists($docIdx))->toBeTrue();
});
});

View File

@ -1,64 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Delete, Field};
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
describe('::byId()', function () {
test('deletes a document when ID is matched', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byId(ThrowawayDb::TABLE, 'four');
expect(Count::all(ThrowawayDb::TABLE))->toBe(4);
});
test('does not delete a document when ID is not matched', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byId(ThrowawayDb::TABLE, 'negative four');
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byFields()', function () {
test('deletes documents when fields match', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byFields(ThrowawayDb::TABLE, [Field::notEqual('value', 'purple')]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(2);
});
test('does not delete documents when fields are not matched', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'crimson')]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byContains()', function () {
test('deletes documents when containment matches', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byContains(ThrowawayDb::TABLE, ['value' => 'purple']);
expect(Count::all(ThrowawayDb::TABLE))->toBe(3);
});
test('does not delete documents when containment is not matched', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byContains(ThrowawayDb::TABLE, ['target' => 'acquired']);
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byJsonPath()', function () {
test('deletes documents when path matches', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ <> 0)');
expect(Count::all(ThrowawayDb::TABLE))->toBe(1);
});
test('does not delete documents when path is not matched', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ < 0)');
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});

View File

@ -1,96 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, DocumentList, Query};
use BitBadger\PDODocument\Mapper\DocumentMapper;
use Test\Integration\PostgreSQL\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'postgresql');
describe('::create()', function () {
test('creates a document list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$list = null; // free database result
});
});
describe('->items', function () {
test('enumerates items in the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$count = 0;
foreach ($list->items as $item) {
expect(['one', 'two', 'three', 'four', 'five'])->toContain($item->id);
$count++;
}
expect($count)->toBe(5);
});
test('fails when the list is exhausted', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
$ignored = iterator_to_array($list->items);
expect($list)->hasItems->toBeFalse()
->and(fn () => iterator_to_array($list->items))->toThrow(DocumentException::class);
});
});
describe('->hasItems', function () {
test('returns false when no items are in the list', function () {
expect(DocumentList::create(
Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE (data->>'num_value')::numeric < 0", [],
new DocumentMapper(TestDocument::class)))
->not->toBeNull()
->hasItems->toBeFalse();
});
test('returns true when items are in the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
foreach ($list->items as $ignored) {
expect($list)->hasItems->toBeTrue();
}
expect($list)->hasItems->toBeFalse();
});
});
describe('->map()', function () {
test('transforms the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
foreach ($list->map(fn($doc) => strrev($doc->id)) as $mapped) {
expect(['eno', 'owt', 'eerht', 'ruof', 'evif'])->toContain($mapped);
}
});
});
describe('->iter()', function () {
test('walks the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
$splats = [];
$list->iter(function ($doc) use (&$splats) { $splats[] = str_repeat('*', strlen($doc->id)); });
expect(implode(' ', $splats))->toBe('*** *** ***** **** ****');
});
});
describe('->mapToArray()', function () {
test('creates an associative array', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue()
->and($list->mapToArray(fn($it) => $it->id, fn($it) => $it->value))
->toBe(['one' => 'FIRST!', 'two' => 'another', 'three' => '', 'four' => 'purple', 'five' => 'purple']);
});
});

View File

@ -1,249 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{AutoId, Configuration, Custom, Document, DocumentException, Field, Find, Query};
use BitBadger\PDODocument\Mapper\ArrayMapper;
use Test\Integration\{NumDocument, SubDocument, TestDocument};
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
describe('::insert()', function () {
test('inserts an array with no automatic ID', function () {
Document::insert(ThrowawayDb::TABLE, ['id' => 'turkey', 'sub' => ['foo' => 'gobble', 'bar' => 'gobble']]);
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'turkey', TestDocument::class);
expect($tryDoc)
->isSome->toBeTrue()
->and($tryDoc->value)
->id->toBe('turkey')
->num_value->toBe(0)
->sub->not->toBeNull()
->sub->foo->toBe('gobble')
->sub->bar->toBe('gobble')
->and($tryDoc->value->value)->toBe('');
});
test('inserts an array with auto-number ID, not provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 0, 'value' => 'new', 'num_value' => 8]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE, [], new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))
->id->toBe(1);
Document::insert(ThrowawayDb::TABLE, ['id' => 0, 'value' => 'again', 'num_value' => 7]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE " . Query::whereById(docId: 2),
[':id' => 2], new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))
->id->toBe(2);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-number ID, provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 7, 'value' => 'new', 'num_value' => 8]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE, [], new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))
->id->toBe(7);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-UUID ID, not provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => '', 'num_value' => 5]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 5)], TestDocument::class);
expect($doc)
->isSome->toBeTrue()
->and($doc->value)->id->not->toBeEmpty();
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-UUID ID, provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
$uuid = AutoId::generateUUID();
Document::insert(ThrowawayDb::TABLE, ['id' => $uuid, 'value' => 'uuid', 'num_value' => 12]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 12)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe($uuid);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-string ID, not provided', function () {
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 6;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => '', 'value' => 'new', 'num_value' => 8]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 8)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toHaveLength(6);
} finally {
Configuration::$autoId = AutoId::None;
Configuration::$idStringLength = 16;
}
});
test('inserts an array with auto-string ID, provided', function () {
Configuration::$autoId = AutoId::RandomString;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 'my-key', 'value' => 'old', 'num_value' => 3]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 3)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe('my-key');
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with no automatic ID', function () {
Document::insert(ThrowawayDb::TABLE, new TestDocument('turkey', sub: new SubDocument('gobble', 'gobble')));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'turkey', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue();
$doc = $tryDoc->value;
expect($doc)
->id->toBe('turkey')
->num_value->toBe(0)
->sub->not->toBeNull()
->sub->foo->toBe('gobble')
->sub->bar->toBe('gobble')
->and($doc->value)->toBe('');
});
test('inserts an object with auto-number ID, not provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new NumDocument(value: 'taco'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'taco')], NumDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe(1);
Document::insert(ThrowawayDb::TABLE, new NumDocument(value: 'burrito'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'burrito')], NumDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe(2);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-number ID, provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new NumDocument(64, 'large'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'large')], NumDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe(64);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-UUID ID, not provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument(value: 'something', num_value: 9));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::exists('value')], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->not->toBeEmpty();
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-UUID ID, provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
$uuid = AutoId::generateUUID();
Document::insert(ThrowawayDb::TABLE, new TestDocument($uuid, num_value: 14));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 14)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe($uuid);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-string ID, not provided', function () {
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 40;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument(num_value: 55));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 55)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toHaveLength(40);
} finally {
Configuration::$autoId = AutoId::None;
Configuration::$idStringLength = 16;
}
});
test('inserts an object with auto-string ID, provided', function () {
Configuration::$autoId = AutoId::RandomString;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument('my-key', num_value: 3));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 3)], TestDocument::class);
expect($doc)->isSome->toBeTrue()
->and($doc->value)->id->toBe('my-key');
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('throws an exception for duplicate key', function () {
expect(fn () => Document::insert(ThrowawayDb::TABLE, new TestDocument('one')))
->toThrow(DocumentException::class);
});
});
describe('::save()', function () {
test('inserts a new document', function () {
Document::save(ThrowawayDb::TABLE, new TestDocument('test', sub: new SubDocument('a', 'b')));
expect(Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class))->isSome->toBeTrue();
});
test('updates an existing document', function () {
Document::save(ThrowawayDb::TABLE, new TestDocument('two', num_value: 44));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->num_value->toBe(44)
->sub->toBeNull();
});
});
describe('::update()', function () {
test('replaces an existing document', function () {
Document::update(ThrowawayDb::TABLE, 'one', new TestDocument('one', 'howdy', 8, new SubDocument('y', 'z')));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue();
$doc = $tryDoc->value;
expect($doc)
->num_value->toBe(8)
->sub->not->toBeNull()
->sub->foo->toBe('y')
->sub->bar->toBe('z')
->and($doc->value)->toBe('howdy');
});
test('does nothing for a non-existent document', function () {
Document::update(ThrowawayDb::TABLE, 'two-hundred', new TestDocument('200'));
expect(Find::byId(ThrowawayDb::TABLE, 'two-hundred', TestDocument::class))->isNone->toBeTrue();
});
});

View File

@ -1,48 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Exists, Field};
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
describe('::byId()', function () {
test('returns true when a document exists', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'three'))->toBeTrue();
});
test('returns false when a document does not exist', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'seven'))->toBeFalse();
});
});
describe('::byFields()', function () {
test('returns true when matching documents exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 10)]))->toBeTrue();
});
test('returns false when no matching documents exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::less('nothing', 'none')]))->toBeFalse();
});
});
describe('::byContains()', function () {
test('returns true when matching documents exist', function () {
expect(Exists::byContains(ThrowawayDb::TABLE, ['value' => 'purple']))->toBeTrue();
});
test('returns false when no matching documents exist', function () {
expect(Exists::byContains(ThrowawayDb::TABLE, ['value' => 'violet']))->toBeFalse();
});
});
describe('::byJsonPath()', function () {
test('returns true when matching documents exist', function () {
expect(Exists::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ == 10)'))->toBeTrue();
});
test('returns false when no matching documents exist', function () {
expect(Exists::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ == 10.1)'))->toBeFalse();
});
});

View File

@ -1,214 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Delete, Document, Field, FieldMatch, Find};
use Test\Integration\{ArrayDocument, NumDocument, TestDocument};
use Test\Integration\PostgreSQL\ThrowawayDb;
pest()->group('integration', 'postgresql');
describe('::all()', function () {
test('retrieves data', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(5);
});
test('sorts data ascending', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['five', 'four', 'one', 'three', 'two']);
});
test('sorts data descending', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id DESC')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['two', 'three', 'one', 'four', 'five']);
});
test('sorts data numerically', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class,
[Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['two', 'four', 'one', 'three', 'five']);
});
test('retrieves empty results', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Find::all(ThrowawayDb::TABLE, TestDocument::class))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::byId()', function () {
test('retrieves a document via string ID', function () {
$doc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('two');
});
test('retrieves a document via numeric ID', function () {
Delete::byFields(ThrowawayDb::TABLE, [Field::notExists('absent')]);
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
$doc = Find::byId(ThrowawayDb::TABLE, 18, NumDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe(18);
});
test('returns None when a document is not found', function () {
expect(Find::byId(ThrowawayDb::TABLE, 'seventy-five', TestDocument::class))->isNone->toBeTrue();
});
});
describe('::byFields()', function () {
test('retrieves matching documents', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
TestDocument::class, FieldMatch::All);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(1);
});
test('retrieves ordered matching documents', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], TestDocument::class,
FieldMatch::All, [Field::named('id')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))->toBe(['five', 'four']);
});
test('retrieves documents matching a numeric IN clause', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])], TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(1);
});
test('returns an empty list when no matching documents are found', function () {
expect(Find::byFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)], TestDocument::class))
->not->toBeNull()
->hasItems->toBeFalse();
});
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);
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])],
ArrayDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(2);
});
test('returns an empty list 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(Find::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])],
ArrayDocument::class))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::byContains()', function () {
test('retrieves matching documents', function () {
$docs = Find::byContains(ThrowawayDb::TABLE, ['value' => 'purple'], TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(2);
});
test('retrieves ordered matching documents', function () {
$docs = Find::byContains(ThrowawayDb::TABLE, ['sub' => ['foo' => 'green']], TestDocument::class,
[Field::named('value')]);
expect($docs)
->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))->toBe(['two', 'four']);
});
test('returns an empty list when no documents match', function () {
expect(Find::byContains(ThrowawayDb::TABLE, ['value' => 'indigo'], TestDocument::class))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::byJsonPath()', function () {
test('retrieves matching documents', function () {
$docs = Find::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(2);
});
test('retrieves ordered matching documents', function () {
$docs = Find::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class,
[Field::named('id')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))->toBe(['five', 'four']);
});
test('returns an empty list when no documents match', function () {
expect(Find::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)', TestDocument::class))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::firstByFields()', function () {
test('retrieves a matching document', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('two');
});
test('retrieves a document for multiple results', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and(['two', 'four'])->toContain($doc->value->id);
});
test('retrieves a document for multiple ordered results', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class,
orderBy: [Field::named('n:num_value DESC')]);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('four');
});
test('returns None when no documents match', function () {
expect(Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')], TestDocument::class))
->isNone->toBeTrue();
});
});
describe('::firstByContains()', function () {
test('retrieves a matching document', function () {
$doc = Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'FIRST!'], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('one');
});
test('retrieves a document for multiple results', function () {
$doc = Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'purple'], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and(['four', 'five'])->toContain($doc->value->id);
});
test('retrieves a document for multiple ordered results', function () {
$doc = Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'purple'], TestDocument::class,
[Field::named('sub.bar NULLS FIRST')]);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('five');
});
test('returns None when no documents match', function () {
expect(Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'indigo'], TestDocument::class))
->isNone->toBeTrue();
});
});
describe('::firstByJsonPath()', function () {
test('retrieves a matching document', function () {
$doc = Find::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ == 10)', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('two');
});
test('retrieves a document for multiple results', function () {
$doc = Find::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and(['four', 'five'])->toContain($doc->value->id);
});
test('retrieves a document for multiple ordered results', function () {
$doc = Find::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class,
[Field::named('id DESC')]);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('four');
});
test('returns None when no documents match', function () {
expect(Find::firstByJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 100)', TestDocument::class))
->isNone->toBeTrue();
});
});

View File

@ -1,406 +0,0 @@
<?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

@ -1,71 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Exists, Field, Find, Patch};
use Test\Integration\PostgreSQL\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'postgresql');
describe('::byId()', function () {
test('updates an existing document', function () {
Patch::byId(ThrowawayDb::TABLE, 'one', ['num_value' => 44]);
$doc = Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->num_value->toBe(44);
});
test('does nothing when a document does not exist', function () {
$id = 'forty-seven';
expect(Exists::byId(ThrowawayDb::TABLE, $id))->toBeFalse();
Patch::byId(ThrowawayDb::TABLE, $id, ['foo' => 'green']); // no exception = pass
});
});
describe('::byFields()', function () {
test('updates existing documents', function () {
Patch::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], ['num_value' => 77]);
expect(Count::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 77)]))->toBe(2);
});
test('does nothing when no matching documents exist', function () {
$fields = [Field::equal('value', 'burgundy')];
expect(Count::byFields(ThrowawayDb::TABLE, $fields))->toBe(0);
Patch::byFields(ThrowawayDb::TABLE, $fields, ['foo' => 'green']); // no exception = pass
});
});
describe('::byContains()', function () {
test('updates existing documents', function () {
Patch::byContains(ThrowawayDb::TABLE, ['value' => 'another'], ['num_value' => 12]);
$tryDoc = Find::firstByContains(ThrowawayDb::TABLE, ['value' => 'another'], TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->id->toBe('two')
->num_value->toBe(12);
});
test('does nothing when no matching documents exist', function () {
$criteria = ['value' => 'updated'];
expect(Count::byContains(ThrowawayDb::TABLE, $criteria))->toBe(0);
Patch::byContains(ThrowawayDb::TABLE, $criteria, ['sub.foo' => 'green']); // no exception = pass
});
});
describe('::byJsonPath()', function () {
test('updates existing documents', function () {
Patch::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', ['value' => 'blue']);
$docs = Find::byJsonPath(ThrowawayDb::TABLE, '$.num_value ? (@ > 10)', TestDocument::class);
expect($docs)->not->toBeNull()->hasItems->toBeTrue();
foreach ($docs->items as $item) {
expect(['four', 'five'])->toContain($item->id)
->and($item->value)->toBe('blue');
}
});
test('does nothing when no matching documents exist', function () {
$path = '$.num_value ? (@ > 100)';
expect(Count::byJsonPath(ThrowawayDb::TABLE, $path))->toBe(0);
Patch::byJsonPath(ThrowawayDb::TABLE, $path, ['value' => 'blue']); // no exception = pass
});
});

View File

@ -1,90 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Exists, Field, Find, RemoveFields};
use Test\Integration\PostgreSQL\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'postgresql');
describe('::byId()', function () {
test('removes fields', function () {
RemoveFields::byId(ThrowawayDb::TABLE, 'two', ['sub', 'value']);
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue();
$doc = $tryDoc->value;
expect($doc)->sub->toBeNull()
->and($doc->value)->toBeEmpty();
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('a_field_that_does_not_exist')]))->toBeFalse();
RemoveFields::byId(ThrowawayDb::TABLE, 'one', ['a_field_that_does_not_exist']); // no exception = pass
});
test('does nothing when the document does not exist', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'fifty'))->toBeFalse();
RemoveFields::byId(ThrowawayDb::TABLE, 'fifty', ['sub']); // no exception = pass
});
});
describe('::byFields()', function () {
test('removes fields from matching documents', function () {
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], ['sub']);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->sub->toBeNull();
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('nada')]))->toBeFalse();
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], ['nada']); // no exception = pass
});
test('does nothing when no documents match', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::notEqual('missing', 'nope')]))->toBeFalse();
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::notEqual('missing', 'nope')], ['value']); // no exn = pass
});
});
describe('::byContains()', function () {
test('removes fields from matching documents', function () {
$criteria = ['sub' => ['foo' => 'green']];
RemoveFields::byContains(ThrowawayDb::TABLE, $criteria, ['value']);
$docs = Find::byContains(ThrowawayDb::TABLE, $criteria, TestDocument::class);
expect($docs)->not->toBeNull()->hasItems->toBeTrue();
foreach ($docs->items as $item) {
expect(['two', 'four'])->toContain($item->id)
->and($item->value)->toBeEmpty();
}
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('invalid_field')]))->toBeFalse();
RemoveFields::byContains(ThrowawayDb::TABLE, ['sub' => ['foo' => 'green']], ['invalid_field']); // no exn = pass
});
test('does nothing when no documents match', function () {
expect(Exists::byContains(ThrowawayDb::TABLE, ['value' => 'substantial']))->toBeFalse();
RemoveFields::byContains(ThrowawayDb::TABLE, ['value' => 'substantial'], ['num_value']); // no exception = pass
});
});
describe('::byJsonPath()', function () {
test('removes fields from matching documents', function () {
$path = '$.value ? (@ == "purple")';
RemoveFields::byJsonPath(ThrowawayDb::TABLE, $path, ['sub']);
$docs = Find::byJsonPath(ThrowawayDb::TABLE, $path, TestDocument::class);
expect($docs)->not->toBeNull()->hasItems->toBeTrue();
foreach ($docs->items as $item) {
expect(['four', 'five'])->toContain($item->id)
->and($item->sub)->toBeNull();
}
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('submarine')]))->toBeFalse();
RemoveFields::byJsonPath(ThrowawayDb::TABLE, '$.value ? (@ == "purple")', ['submarine']); // no exception = pass
});
test('does nothing when no documents match', function () {
expect(Exists::byJsonPath(ThrowawayDb::TABLE, '$.value ? (@ == "mauve")'))->toBeFalse();
RemoveFields::byJsonPath(ThrowawayDb::TABLE, '$.value ? (@ == "mauve")', ['value']); // no exception = pass
});
});

View File

@ -1,92 +0,0 @@
<?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\PostgreSQL;
use BitBadger\PDODocument\{AutoId, Configuration, Custom, Definition, Document, DocumentException};
use Random\RandomException;
use Test\Integration\{SubDocument, TestDocument};
/**
* Utilities to create and destroy a throwaway PostgreSQL database to use for testing
*/
class ThrowawayDb
{
/** @var string The table used for document manipulation */
public const TABLE = 'test_table';
/**
* Configure the document library for the given database (or the main PostgreSQL connection, if the database name
* is not provided; this is used for creating and dropping databases)
*
* @param string|null $dbName The name of the database to configure (optional, defaults to env or "postgres")
* @throws DocumentException If any is encountered
*/
private static function configure(?string $dbName = null): void
{
Configuration::useDSN(sprintf("pgsql:host=%s;dbname=%s", $_ENV['PDO_DOC_PGSQL_HOST'] ?? 'localhost',
$dbName ?? $_ENV['PDO_DOC_PGSQL_DB'] ?? 'postgres'));
Configuration::$username = $_ENV['PDO_DOC_PGSQL_USER'] ?? 'postgres';
Configuration::$password = $_ENV['PDO_DOC_PGSQL_PASS'] ?? 'postgres';
Configuration::resetPDO();
}
/**
* Load data into the test table
*
* @throws DocumentException If any is encountered
*/
public static function loadData(): void
{
Document::insert(self::TABLE, new TestDocument('one', 'FIRST!', 0));
Document::insert(self::TABLE, new TestDocument('two', 'another', 10, new SubDocument('green', 'blue')));
Document::insert(self::TABLE, new TestDocument('three', '', 4));
Document::insert(self::TABLE, new TestDocument('four', 'purple', 17, new SubDocument('green', 'red')));
Document::insert(self::TABLE, new TestDocument('five', 'purple', 18));
}
/**
* Create a throwaway PostgreSQL database
*
* @param bool $withData Whether to initialize this database with data (optional; defaults to `true`)
* @return string The name of the database (use to pass to `destroy` function at end of test)
* @throws DocumentException|RandomException If any is encountered
*/
public static function create(bool $withData = true): string
{
$dbName = 'throwaway_' . AutoId::generateRandom(10);
self::configure();
Custom::nonQuery("CREATE DATABASE $dbName WITH OWNER " . Configuration::$username, []);
self::configure($dbName);
Definition::ensureTable(self::TABLE);
if ($withData) {
self::loadData();
}
return $dbName;
}
/**
* Destroy a throwaway PostgreSQL database
*
* @param string $dbName The name of the PostgreSQL database to be dropped
* @throws DocumentException If any is encountered
*/
public static function destroy(string $dbName): void
{
self::configure();
Custom::nonQuery("DROP DATABASE IF EXISTS $dbName WITH (FORCE)", []);
Configuration::useDSN('');
Configuration::$username = null;
Configuration::$password = null;
Configuration::resetPDO();
}
}

View File

@ -1,39 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, DocumentException, Field};
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
describe('::all()', function () {
test('counts all documents', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byFields()', function () {
test('counts by numeric range', function () {
expect(Count::byFields(ThrowawayDb::TABLE, [Field::between('num_value', 10, 20)]))->toBe(3);
});
test('counts by non-numeric range', function () {
expect(Count::byFields(ThrowawayDb::TABLE, [Field::between('value', 'aardvark', 'apple')]))->toBe(1);
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Count::byContains('', []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Count::byJsonPath('', ''))->toThrow(DocumentException::class);
});
});

View File

@ -1,131 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Custom, DocumentException, Query};
use BitBadger\PDODocument\Mapper\{CountMapper, DocumentMapper};
use Test\Integration\SQLite\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'sqlite');
describe('::runQuery()', function () {
test('runs a valid query successfully', function () {
$stmt = &Custom::runQuery('SELECT data FROM ' . ThrowawayDb::TABLE . ' LIMIT 1', []);
try {
expect($stmt)->not->toBeNull();
} finally {
$stmt = null;
}
});
test('fails with an invalid query', function () {
$stmt = null;
try {
expect(function () use (&$stmt) { $stmt = &Custom::runQuery('GRAB stuff FROM over_there UNTIL done', []); })
->toThrow(DocumentException::class);
} finally {
$stmt = null;
}
});
});
describe('::list()', function () {
test('returns non-empty list when data is found', function () {
$list = Custom::list(Query::selectFromTable(ThrowawayDb::TABLE), [], new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$count = 0;
foreach ($list->items as $ignored) $count++;
expect($count)->toBe(5);
});
test('returns empty list when not data is found', function () {
expect(Custom::list(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'num_value' > :value",
[':value' => 100], new DocumentMapper(TestDocument::class)))
->not->toBeNull()
->hasItems->toBeFalse();
});
});
describe('::array()', function () {
test('returns non-empty array when data is found', function () {
expect(Custom::array(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'sub' IS NOT NULL", [],
new DocumentMapper(TestDocument::class)))
->not->toBeNull()->toHaveCount(2);
});
test('returns empty array when data is not found', function () {
expect(Custom::array(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'value' = :value",
[':value' => 'not there'], new DocumentMapper(TestDocument::class)))
->not->toBeNull()->toBeEmpty();
});
});
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 () {
test('returns a document when one is found', function () {
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id", [':id' => 'one'],
new DocumentMapper(TestDocument::class));
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('one');
});
test('returns no document when none is found', function () {
expect(Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = :id",
[':id' => 'eighty'], new DocumentMapper(TestDocument::class)))
->isNone->toBeTrue();
});
});
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 () {
test('works when documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Count::all(ThrowawayDb::TABLE))->toBe(0);
});
test('works when no documents match the WHERE clause', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE . " WHERE data->>'num_value' > :value", [':value' => 100]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::scalar()', function () {
test('returns a scalar value', function () {
expect(Custom::scalar("SELECT 5 AS it", [], new CountMapper()))->toBe(5);
});
});

View File

@ -1,36 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Definition, DocumentException, DocumentIndex};
pest()->group('integration', 'sqlite');
describe('::ensureTable()', function () {
test('creates table and PK index', function () {
expect($this->dbObjectExists('ensured'))->toBeFalse()
->and($this->dbObjectExists('idx_ensured_key'))->toBeFalse();
Definition::ensureTable('ensured');
expect($this->dbObjectExists('ensured'))->toBeTrue()
->and($this->dbObjectExists('idx_ensured_key'))->toBeTrue();
});
});
describe('::ensureFieldIndex()', function () {
test('creates an index', function () {
expect($this->dbObjectExists('idx_ensured_test'))->toBeFalse();
Definition::ensureTable('ensured');
Definition::ensureFieldIndex('ensured', 'test', ['name', 'age']);
expect($this->dbObjectExists('idx_ensured_test'))->toBeTrue();
});
});
describe('::ensureDocumentIndex()', function () {
test('throws an exception', function () {
expect(fn () => Definition::ensureDocumentIndex('', DocumentIndex::Full))->toThrow(DocumentException::class);
});
});

View File

@ -1,50 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, Delete, DocumentException, Field};
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
describe('::byId()', function () {
test('deletes a document when one exists', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byId(ThrowawayDb::TABLE, 'four');
expect(Count::all(ThrowawayDb::TABLE))->toBe(4);
});
test('does nothing when the document does not exist', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byId(ThrowawayDb::TABLE, 'negative four');
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byFields()', function () {
test('deletes matching documents', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byFields(ThrowawayDb::TABLE, [Field::notEqual('value', 'purple')]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(2);
});
test('does nothing when no documents match', function () {
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
Delete::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'crimson')]);
expect(Count::all(ThrowawayDb::TABLE))->toBe(5);
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Delete::byContains('', []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Delete::byJsonPath('', ''))->toThrow(DocumentException::class);
});
});

View File

@ -1,94 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, DocumentList, Query};
use BitBadger\PDODocument\Mapper\DocumentMapper;
use Test\Integration\SQLite\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'sqlite');
describe('::create()', function () {
test('creates a document list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$list = null; // free database result
});
});
describe('->items', function () {
test('enumerates items in the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull();
$count = 0;
foreach ($list->items as $item) {
expect(['one', 'two', 'three', 'four', 'five'])->toContain($item->id);
$count++;
}
expect($count)->toBe(5);
});
test('fails when the list is exhausted', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
$ignored = iterator_to_array($list->items);
expect($list)->hasItems->toBeFalse()
->and(fn () => iterator_to_array($list->items))->toThrow(DocumentException::class);
});
});
describe('->hasItems', function () {
test('returns true when items exist', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
foreach ($list->items as $ignored) {
expect($list)->hasItems->toBeTrue();
}
expect($list)->hasItems->toBeFalse();
});
test('returns false when no items exist', function () {
expect(DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE) . " WHERE data->>'num_value' < 0", [],
new DocumentMapper(TestDocument::class)))
->not->toBeNull()->hasItems->toBeFalse();
});
});
describe('->map()', function () {
test('transforms the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
foreach ($list->map(fn($doc) => strrev($doc->id)) as $mapped) {
expect(['eno', 'owt', 'eerht', 'ruof', 'evif'])->toContain($mapped);
}
});
});
describe('->iter()', function () {
test('walks the list', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue();
$splats = [];
$list->iter(function ($doc) use (&$splats) { $splats[] = str_repeat('*', strlen($doc->id)); });
expect(implode(' ', $splats))->toBe('*** *** ***** **** ****');
});
});
describe('->mapToArray()', function () {
test('creates an associative array', function () {
$list = DocumentList::create(Query::selectFromTable(ThrowawayDb::TABLE), [],
new DocumentMapper(TestDocument::class));
expect($list)->not->toBeNull()->hasItems->toBeTrue()
->and($list->mapToArray(fn($it) => $it->id, fn($it) => $it->value))
->toBe(['one' => 'FIRST!', 'two' => 'another', 'three' => '', 'four' => 'purple', 'five' => 'purple']);
});
});

View File

@ -1,231 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{AutoId, Configuration, Custom, Document, DocumentException, Exists, Field, Find};
use BitBadger\PDODocument\Mapper\ArrayMapper;
use Test\Integration\{NumDocument, SubDocument, TestDocument};
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
describe('::insert()', function () {
test('inserts an array with no automatic ID', function () {
Document::insert(ThrowawayDb::TABLE, ['id' => 'turkey', 'sub' => ['foo' => 'gobble', 'bar' => 'gobble']]);
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'turkey', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->id->toBe('turkey')
->num_value->toBe(0)
->sub->not->toBeNull()
->sub->foo->toBe('gobble')
->sub->bar->toBe('gobble')
->and($tryDoc->value->value)->toBeEmpty();
});
test('inserts an array with auto-number ID, not provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 0, 'value' => 'new', 'num_value' => 8]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE, [], new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))->id->toBe(1);
Document::insert(ThrowawayDb::TABLE, ['id' => 0, 'value' => 'again', 'num_value' => 7]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE . " WHERE data->>'id' = 2", [],
new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))->id->toBe(2);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-number ID, provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 7, 'value' => 'new', 'num_value' => 8]);
$doc = Custom::single('SELECT data FROM ' . ThrowawayDb::TABLE, [], new ArrayMapper());
expect($doc)->isSome->toBeTrue()
->and(json_decode($doc->value['data']))->id->toBe(7);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-UUID ID, not provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => '', 'num_value' => 5]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 5)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->not->toBeEmpty();
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-UUID ID, provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
$uuid = AutoId::generateUUID();
Document::insert(ThrowawayDb::TABLE, ['id' => $uuid, 'value' => 'uuid', 'num_value' => 12]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 12)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe($uuid);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an array with auto-string ID, not provided', function () {
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 6;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => '', 'value' => 'new', 'num_value' => 8]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 8)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toHaveLength(6);
} finally {
Configuration::$autoId = AutoId::None;
Configuration::$idStringLength = 16;
}
});
test('inserts an array with auto-string ID, provided', function () {
Configuration::$autoId = AutoId::RandomString;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, ['id' => 'my-key', 'value' => 'old', 'num_value' => 3]);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 3)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('my-key');
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with no automatic ID', function () {
Document::insert(ThrowawayDb::TABLE, new TestDocument('turkey', sub: new SubDocument('gobble', 'gobble')));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'turkey', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->id->toBe('turkey')
->num_value->toBe(0)
->sub->not->toBeNull()
->sub->foo->toBe('gobble')
->sub->bar->toBe('gobble')
->and($tryDoc->value->value)->toBeEmpty();
});
test('inserts an object with auto-number ID, not provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new NumDocument(value: 'taco'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'taco')], NumDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe(1);
Document::insert(ThrowawayDb::TABLE, new NumDocument(value: 'burrito'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'burrito')], NumDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe(2);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-number ID, provided', function () {
Configuration::$autoId = AutoId::Number;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new NumDocument(64, 'large'));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'large')], NumDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe(64);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-UUID ID, not provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument(value: 'something', num_value: 9));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::exists('value')], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->not->toBeEmpty();
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-UUID ID, provided', function () {
Configuration::$autoId = AutoId::UUID;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
$uuid = AutoId::generateUUID();
Document::insert(ThrowawayDb::TABLE, new TestDocument($uuid, num_value: 14));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 14)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe($uuid);
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('inserts an object with auto-string ID, not provided', function () {
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 40;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument(num_value: 55));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 55)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toHaveLength(40);
} finally {
Configuration::$autoId = AutoId::None;
Configuration::$idStringLength = 16;
}
});
test('inserts an object with auto-string ID, provided', function () {
Configuration::$autoId = AutoId::RandomString;
try {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
Document::insert(ThrowawayDb::TABLE, new TestDocument('my-key', num_value: 3));
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 3)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('my-key');
} finally {
Configuration::$autoId = AutoId::None;
}
});
test('throws an exception for duplicate key', function () {
expect(fn () => Document::insert(ThrowawayDb::TABLE, new TestDocument('one')))
->toThrow(DocumentException::class);
});
});
describe('::save()', function () {
test('inserts a new document', function () {
Document::save(ThrowawayDb::TABLE, new TestDocument('test', sub: new SubDocument('a', 'b')));
expect(Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class))->isSome->toBeTrue();
});
test('updates an existing document', function () {
Document::save(ThrowawayDb::TABLE, new TestDocument('two', num_value: 44));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->num_value->toBe(44)
->sub->toBeNull();
});
});
describe('::update()', function () {
test('replaces an existing document', function () {
Document::update(ThrowawayDb::TABLE, 'one', new TestDocument('one', 'howdy', 8, new SubDocument('y', 'z')));
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)
->num_value->toBe(8)
->sub->not->toBeNull()
->sub->foo->toBe('y')
->sub->bar->toBe('z')
->and($tryDoc->value->value)->toBe('howdy');
});
test('does nothing for a non-existent document', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'two-hundred'))->toBeFalse();
Document::update(ThrowawayDb::TABLE, 'two-hundred', new TestDocument('200'));
expect(Find::byId(ThrowawayDb::TABLE, 'two-hundred', TestDocument::class))->isNone->toBeTrue();
});
});

View File

@ -1,42 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, Exists, Field};
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
describe('::byId()', function () {
test('returns true when a document exists', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'three'))->toBeTrue();
});
test('returns false when no document exists', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'seven'))->toBeFalse();
});
});
describe('::byFields()', function () {
test('returns true when matching documents exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 10)]))->toBeTrue();
});
test('returns false when no matching documents exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::less('nothing', 'none')]))->toBeFalse();
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Exists::byContains('', []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Exists::byJsonPath('', ''))->toThrow(DocumentException::class);
});
});

View File

@ -1,151 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Delete, Document, DocumentException, Field, FieldMatch, Find};
use Test\Integration\{ArrayDocument, TestDocument};
use Test\Integration\SQLite\ThrowawayDb;
pest()->group('integration', 'sqlite');
describe('::all()', function () {
test('retrieves data', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(5);
});
test('sorts data ascending', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['five', 'four', 'one', 'three', 'two']);
});
test('sorts data descending', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class, [Field::named('id DESC')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['two', 'three', 'one', 'four', 'five']);
});
test('sorts data numerically', function () {
$docs = Find::all(ThrowawayDb::TABLE, TestDocument::class,
[Field::named('sub.foo NULLS LAST'), Field::named('n:num_value')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))
->toBe(['two', 'four', 'one', 'three', 'five']);
});
test('returns an empty list when no data exists', function () {
Custom::nonQuery('DELETE FROM ' . ThrowawayDb::TABLE, []);
expect(Find::all(ThrowawayDb::TABLE, TestDocument::class))
->not->toBeNull()->hasItems->toBeFalse();
});
});
describe('::byId()', function () {
test('returns a document when it exists', function () {
$doc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('two');
});
test('returns a document with a numeric ID', function () {
Document::insert(ThrowawayDb::TABLE, ['id' => 18, 'value' => 'howdy']);
$doc = Find::byId(ThrowawayDb::TABLE, 18, TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('18');
});
test('returns None when no document exists', function () {
expect(Find::byId(ThrowawayDb::TABLE, 'seventy-five', TestDocument::class))->isNone->toBeTrue();
});
});
describe('::byFields()', function () {
test('returns matching documents', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('value', ['blue', 'purple']), Field::exists('sub')],
TestDocument::class, FieldMatch::All);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(1);
});
test('returns ordered matching documents', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], TestDocument::class,
FieldMatch::All, [Field::named('id')]);
expect($docs)->not->toBeNull()
->and(iterator_to_array($docs->map(fn ($it) => $it->id), false))->toBe(['five', 'four']);
});
test('returns documents matching numeric IN clause', function () {
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::in('num_value', [2, 4, 6, 8])], TestDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(1);
});
test('returns empty list when no documents match', function () {
expect(Find::byFields(ThrowawayDb::TABLE, [Field::greater('num_value', 100)], TestDocument::class))
->not->toBeNull()->hasItems->toBeFalse();
});
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);
$docs = Find::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['c'])],
ArrayDocument::class);
expect($docs)->not->toBeNull();
$count = 0;
foreach ($docs->items as $ignored) $count++;
expect($count)->toBe(2);
});
test('returns empty list 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(Find::byFields(ThrowawayDb::TABLE, [Field::inArray('values', ThrowawayDb::TABLE, ['j'])],
ArrayDocument::class))
->not->toBeNull()->hasItems->toBeFalse();
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Find::byContains('', [], TestDocument::class))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Find::byJsonPath('', '', TestDocument::class))->toThrow(DocumentException::class);
});
});
describe('::firstByFields()', function () {
test('returns a matching document', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'another')], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('two');
});
test('returns one of several matching documents', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and(['two', 'four'])->toContain($doc->value->id);
});
test('returns first of ordered matching documents', function () {
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('sub.foo', 'green')], TestDocument::class,
orderBy: [Field::named('n:num_value DESC')]);
expect($doc)->isSome->toBeTrue()->and($doc->value)->id->toBe('four');
});
test('returns None when no documents match', function () {
expect(Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('value', 'absent')], TestDocument::class))
->isNone->toBeTrue();
});
});
describe('::firstByContains()', function () {
test('throws an exception', function () {
expect(fn () => Find::firstByContains('', [], TestDocument::class))->toThrow(DocumentException::class);
});
});
describe('::firstByJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Find::firstByJsonPath('', '', TestDocument::class))->toThrow(DocumentException::class);
});
});

View File

@ -1,274 +0,0 @@
<?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

@ -1,48 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Count, DocumentException, Exists, Field, Find, Patch};
use Test\Integration\SQLite\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'sqlite');
describe('::byId()', function () {
test('updates an existing document', function () {
Patch::byId(ThrowawayDb::TABLE, 'one', ['num_value' => 44]);
$doc = Find::byId(ThrowawayDb::TABLE, 'one', TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->num_value->toBe(44);
});
test('does nothing when no document exists', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'forty-seven'))->toBeFalse();
Patch::byId(ThrowawayDb::TABLE, 'forty-seven', ['foo' => 'green']);
});
});
describe('::byFields()', function () {
test('updates matching documents', function () {
Patch::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'purple')], ['num_value' => 77]);
expect(Count::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 77)]))->toBe(2);
});
test('does nothing when no documents match', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'burgundy')]))->toBeFalse();
Patch::byFields(ThrowawayDb::TABLE, [Field::equal('value', 'burgundy')], ['foo' => 'green']);
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => Patch::byContains('', [], []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => Patch::byJsonPath('', '', []))->toThrow(DocumentException::class);
});
});

View File

@ -1,59 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, Exists, Field, Find, RemoveFields};
use Test\Integration\SQLite\ThrowawayDb;
use Test\Integration\TestDocument;
pest()->group('integration', 'sqlite');
describe('::byId()', function () {
test('updates an existing document', function () {
RemoveFields::byId(ThrowawayDb::TABLE, 'two', ['sub', 'value']);
$tryDoc = Find::byId(ThrowawayDb::TABLE, 'two', TestDocument::class);
expect($tryDoc)->isSome->toBeTrue()
->and($tryDoc->value)->sub->toBeNull()
->and($tryDoc->value->value)->toBeEmpty();
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('a_field_that_does_not_exist')]))->toBeFalse();
RemoveFields::byId(ThrowawayDb::TABLE, 'one', ['a_field_that_does_not_exist']);
});
test('does nothing when the document does not exist', function () {
expect(Exists::byId(ThrowawayDb::TABLE, 'fifty'))->toBeFalse();
RemoveFields::byId(ThrowawayDb::TABLE, 'fifty', ['sub']);
});
});
describe('::byFields()', function () {
test('updates matching documents', function () {
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], ['sub']);
$doc = Find::firstByFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], TestDocument::class);
expect($doc)->isSome->toBeTrue()->and($doc->value)->sub->toBeNull();
});
test('does nothing when the field to remove does not exist', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::exists('nada')]))->toBeFalse();
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::equal('num_value', 17)], ['nada']);
});
test('does nothing when no documents match', function () {
expect(Exists::byFields(ThrowawayDb::TABLE, [Field::notEqual('missing', 'nope')]))->toBeFalse();
RemoveFields::byFields(ThrowawayDb::TABLE, [Field::notEqual('missing', 'nope')], ['value']);
});
});
describe('::byContains()', function () {
test('throws an exception', function () {
expect(fn () => RemoveFields::byContains('', [], []))->toThrow(DocumentException::class);
});
});
describe('::byJsonPath()', function () {
test('throws an exception', function () {
expect(fn () => RemoveFields::byJsonPath('', '', []))->toThrow(DocumentException::class);
});
});

View File

@ -1,70 +0,0 @@
<?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\SQLite;
use BitBadger\PDODocument\{AutoId, Configuration, Definition, Document, DocumentException};
use Random\RandomException;
use Test\Integration\{SubDocument, TestDocument};
/**
* Utilities to create and destroy a throwaway SQLite database to use for testing
*/
class ThrowawayDb
{
/** @var string The table used for document manipulation */
public const TABLE = 'test_table';
/**
* Load data into the test table
*
* @throws DocumentException If any is encountered
*/
public static function loadData(): void
{
Document::insert(self::TABLE, new TestDocument('one', 'FIRST!', 0));
Document::insert(self::TABLE, new TestDocument('two', 'another', 10, new SubDocument('green', 'blue')));
Document::insert(self::TABLE, new TestDocument('three', '', 4));
Document::insert(self::TABLE, new TestDocument('four', 'purple', 17, new SubDocument('green', 'red')));
Document::insert(self::TABLE, new TestDocument('five', 'purple', 18));
}
/**
* Create a throwaway SQLite database
*
* @param bool $withData Whether to initialize this database with data (optional; defaults to `true`)
* @return string The name of the database (use to pass to `destroy` function at end of test)
* @throws DocumentException|RandomException If any is encountered
*/
public static function create(bool $withData = true): string
{
$fileName = sprintf('throwaway-%s.db', AutoId::generateRandom(10));
Configuration::useDSN("sqlite:./$fileName");
Configuration::resetPDO();
Definition::ensureTable(self::TABLE);
if ($withData) {
self::loadData();
}
return $fileName;
}
/**
* Destroy a throwaway SQLite database
*
* @param string $fileName The name of the SQLite database to be deleted
*/
public static function destroy(string $fileName): void
{
Configuration::resetPDO();
if (file_exists("./$fileName")) unlink("./$fileName");
}
}

View File

@ -1,59 +0,0 @@
<?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 BitBadger\PDODocument\{Configuration, Custom, Delete, DocumentException, Field};
use BitBadger\PDODocument\Mapper\ExistsMapper;
use Test\Integration\SQLite\ThrowawayDb;
/**
* Integration Test Class wrapper for SQLite integration tests
*/
class SQLiteIntegrationTest extends DocumentTestCase
{
/** @var string Database name for throwaway database */
static private string $dbName = '';
public static function setUpBeforeClass(): void
{
self::$dbName = ThrowawayDb::create(false);
}
protected function setUp(): void
{
parent::setUp();
ThrowawayDb::loadData();
}
protected function tearDown(): void
{
Delete::byFields(ThrowawayDb::TABLE, [ Field::exists(Configuration::$idField)]);
parent::tearDown();
}
public static function tearDownAfterClass(): void
{
ThrowawayDb::destroy(self::$dbName);
self::$dbName = '';
}
/**
* Does the given named object exist in the database?
*
* @param string $name The name of the object whose existence should be verified
* @return bool True if the object exists, false if not
* @throws DocumentException If any is encountered
*/
protected function dbObjectExists(string $name): bool
{
return Custom::scalar('SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE name = :name)',
[':name' => $name], new ExistsMapper());
}
}

View File

@ -1,55 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
// pest()->extend(Tests\TestCase::class)->in('Feature');
pest()->extend(Test\Integration\PgIntegrationTest::class)->in('Integration/PostgreSQL');
pest()->extend(Test\Integration\SQLiteIntegrationTest::class)->in('Integration/SQLite');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
/**
* Reset the database mode
*/
function reset_mode(): void
{
\BitBadger\PDODocument\Configuration::overrideMode(null);
}
function something()
{
// ..
}

View File

@ -1,22 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use Square\Pjson\{Json, JsonSerialize};
/**
* A test document annotated with pjson attributes using the `JsonSerialize` trait
*/
class PjsonDocument
{
use JsonSerialize;
public function __construct(#[Json] public PjsonId $id = new PjsonId(''), #[Json] public string $name = '',
#[Json('num_value')] public int $numValue = 0, public string $skipped = 'yep') { }
}

View File

@ -1,34 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use Square\Pjson\JsonDataSerializable;
/**
* A serializable ID wrapper class
*/
final class PjsonId implements JsonDataSerializable
{
public function __construct(protected string $value) { }
public function toJsonData(): string
{
return $this->value;
}
/**
* @param mixed $jd JSON data
* @param mixed[]|string $path path segments
* @return static
*/
public static function fromJsonData($jd, array|string $path = []): static
{
return new static($jd);
}
}

View File

@ -1,36 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{AutoId, Configuration, DocumentException};
pest()->group('unit');
describe('::$idField', function () {
test('has expected default value', function () {
expect(Configuration::$idField)->toBe('id');
});
});
describe('::$autoId', function () {
test('has expected default value', function () {
expect(Configuration::$autoId)->toBe(AutoId::None);
});
});
describe('::$idStringLength', function () {
test('has expected default value', function () {
expect(Configuration::$idStringLength)->toBe(16);
});
});
describe('::dbConn()', function () {
test('throws if DSN has not been set', function () {
Configuration::useDSN('');
expect(fn() => Configuration::dbConn())->toThrow(DocumentException::class);
});
});

View File

@ -1,40 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\DocumentException;
pest()->group('unit');
describe('Constructor', function () {
test('fills code and prior exception if provided', function () {
$priorEx = new Exception('Uh oh');
expect(new DocumentException('Test Exception', 17, $priorEx))
->not->toBeNull()
->getMessage()->toBe('Test Exception')
->getCode()->toBe(17)
->getPrevious()->toBe($priorEx);
});
test('uses expected code and prior exception if not provided', function () {
expect(new DocumentException('Oops'))
->not->toBeNull()
->getMessage()->toBe('Oops')
->getCode()->toBe(0)
->getPrevious()->toBeNull();
});
});
describe('->__toString()', function () {
test('excludes code if 0', function () {
$ex = new DocumentException('Test failure');
expect("$ex")->toBe("BitBadger\PDODocument\DocumentException: Test failure\n");
});
test('includes code if non-zero', function () {
$ex = new DocumentException('Oof', -6);
expect("$ex")->toBe("BitBadger\PDODocument\DocumentException: [-6] Oof\n");
});
});

View File

@ -1,20 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\FieldMatch;
pest()->group('unit');
describe('->toSQL()', function () {
test('returns AND for All', function () {
expect(FieldMatch::All)->toSQL()->toBe('AND');
});
test('returns OR for Any', function () {
expect(FieldMatch::Any)->toSQL()->toBe('OR');
});
});

View File

@ -1,418 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Configuration, Field, Mode, Op};
pest()->group('unit');
describe('->appendParameter()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('appends no parameter for exists', function () {
expect(Field::exists('exists')->appendParameter([]))->toBeEmpty();
});
test('appends no parameter for notExists', function () {
expect(Field::notExists('absent')->appendParameter([]))->toBeEmpty();
});
test('appends two parameters for between', function () {
expect(Field::between('exists', 5, 9, '@num')->appendParameter([]))
->toHaveLength(2)
->toEqual(['@nummin' => 5, '@nummax' => 9]);
});
test('appends a parameter for each value for in', function () {
expect(Field::in('it', ['test', 'unit', 'great'], ':val')->appendParameter([]))
->toHaveLength(3)
->toEqual([':val_0' => 'test', ':val_1' => 'unit', ':val_2' => 'great']);
});
test('appends a parameter for each value for inArray [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::inArray('it', 'table', [2, 8, 64], ':bit')->appendParameter([]))
->toHaveLength(3)
->toEqual([':bit_0' => '2', ':bit_1' => '8', ':bit_2' => '64']);
})->group('postgresql');
test('appends a parameter for each value for inArray [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::inArray('it', 'table', [2, 8, 64], ':bit')->appendParameter([]))
->toHaveLength(3)
->toEqual([':bit_0' => 2, ':bit_1' => 8, ':bit_2' => 64]);
})->group('sqlite');
test('appends a parameter for other operators', function () {
expect(Field::equal('the_field', 33, ':test')->appendParameter([]))
->toHaveLength(1)
->toEqual([':test' => 33]);
});
});
describe('->path()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('returns simple SQL path [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::equal('it', 'that'))->path()->toBe("data->>'it'");
})->group('postgresql');
test('returns simple SQL path [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::equal('top', 'that'))->path()->toBe("data->>'top'");
})->group('sqlite');
test('returns nested SQL path [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::equal('parts.to.the.path', ''))->path()->toBe("data#>>'{parts,to,the,path}'");
})->group('postgresql');
test('returns nested SQL path [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::equal('one.two.three', ''))->path()->toBe("data->'one'->'two'->>'three'");
})->group('sqlite');
test('returns simple JSON path [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::equal('it', 'that'))->path(true)->toBe("data->'it'");
})->group('postgresql');
test('returns simple JSON path [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::equal('top', 'that'))->path(true)->toBe("data->'top'");
})->group('sqlite');
test('returns nested JSON path [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::equal('parts.to.the.path', ''))->path(true)->toBe("data#>'{parts,to,the,path}'");
})->group('postgresql');
test('returns nested JSON path [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::equal('one.two.three', ''))->path(true)->toBe("data->'one'->'two'->'three'");
})->group('sqlite');
});
describe('->toWhere()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('generates IS NOT NULL for exists w/o qualifier [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::exists('that_field'))->toWhere()->toBe("data->>'that_field' IS NOT NULL");
})->group('postgresql');
test('generates IS NOT NULL for exists w/o qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::exists('that_field'))->toWhere()->toBe("data->>'that_field' IS NOT NULL");
})->group('sqlite');
test('generates IS NULL for notExists w/o qualifier [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::notExists('a_field'))->toWhere()->toBe("data->>'a_field' IS NULL");
})->group('postgresql');
test('generates IS NULL for notExists w/o qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::notExists('a_field'))->toWhere()->toBe("data->>'a_field' IS NULL");
})->group('sqlite');
test('generates BETWEEN for between w/o qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::between('age', 13, 17, '@age'))->toWhere()->toBe("data->>'age' BETWEEN @agemin AND @agemax");
})->group('sqlite');
test('generates BETWEEN for between w/o qualifier, numeric range [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::between('age', 13, 17, '@age'))->toWhere()
->toBe("(data->>'age')::numeric BETWEEN @agemin AND @agemax");
})->group('postgresql');
test('generates BETWEEN for between w/o qualifier, non-numeric range [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::between('city', 'Atlanta', 'Chicago', ':city'))->toWhere()
->toBe("data->>'city' BETWEEN :citymin AND :citymax");
})->group('postgresql');
test('generates BETWEEN for between w/ qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
$field = Field::between('age', 13, 17, '@age');
$field->qualifier = 'me';
expect($field)->toWhere()->toBe("me.data->>'age' BETWEEN @agemin AND @agemax");
})->group('sqlite');
test('generates BETWEEN for between w/ qualifier, numeric range [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
$field = Field::between('age', 13, 17, '@age');
$field->qualifier = 'me';
expect($field)->toWhere()->toBe("(me.data->>'age')::numeric BETWEEN @agemin AND @agemax");
})->group('postgresql');
test('generates BETWEEN for between w/ qualifier, non-numeric range [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
$field = Field::between('city', 'Atlanta', 'Chicago', ':city');
$field->qualifier = 'me';
expect($field)->toWhere()->toBe("me.data->>'city' BETWEEN :citymin AND :citymax");
})->group('postgresql');
test('generates IN for in, non-numeric values [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::in('test', ['Atlanta', 'Chicago'], ':city'))->toWhere()
->toBe("data->>'test' IN (:city_0, :city_1)");
})->group('postgresql');
test('generates IN for in, numeric values [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::in('even', [2, 4, 6], ':nbr'))->toWhere()
->toBe("(data->>'even')::numeric IN (:nbr_0, :nbr_1, :nbr_2)");
})->group('postgresql');
test('generates IN for in [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::in('test', ['Atlanta', 'Chicago'], ':city'))->toWhere()
->toBe("data->>'test' IN (:city_0, :city_1)");
})->group('sqlite');
test('generates clause for inArray [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::inArray('even', 'tbl', [2, 4, 6, 8], ':it'))->toWhere()
->toBe("data->'even' ??| ARRAY[:it_0, :it_1, :it_2, :it_3]");
})->group('postgresql');
test('generates clause for inArray [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::inArray('test', 'tbl', ['Atlanta', 'Chicago'], ':city'))->toWhere()
->toBe("EXISTS (SELECT 1 FROM json_each(tbl.data, '\$.test') WHERE value IN (:city_0, :city_1))");
})->group('sqlite');
test('generates clause for other operators w/o qualifier [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Field::equal('some_field', '', ':value'))->toWhere()->toBe("data->>'some_field' = :value");
})->group('postgresql');
test('generates clause for other operators w/o qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Field::equal('some_field', '', ':value'))->toWhere()->toBe("data->>'some_field' = :value");
})->group('sqlite');
test('generates no-parameter clause w/ qualifier [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
$field = Field::exists('no_field');
$field->qualifier = 'test';
expect($field)->toWhere()->toBe("test.data->>'no_field' IS NOT NULL");
})->group('postgresql');
test('generates no-parameter clause w/ qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
$field = Field::exists('no_field');
$field->qualifier = 'test';
expect($field)->toWhere()->toBe("test.data->>'no_field' IS NOT NULL");
})->group('sqlite');
test('generates parameter clause w/ qualifier [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
$field = Field::lessOrEqual('le_field', 18, ':it');
$field->qualifier = 'q';
expect($field)->toWhere()->toBe("(q.data->>'le_field')::numeric <= :it");
})->group('postgresql');
test('generates parameter clause w/ qualifier [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
$field = Field::lessOrEqual('le_field', 18, ':it');
$field->qualifier = 'q';
expect($field)->toWhere()->toBe("q.data->>'le_field' <= :it");
})->group('sqlite');
});
describe('::equal()', function () {
test('creates Field w/o parameter', function () {
$field = Field::equal('my_test', 9);
expect($field)
->not->toBeNull()
->fieldName->toBe('my_test')
->op->toBe(Op::Equal)
->paramName->toBeEmpty()
->and($field->value)->toBe(9);
});
test('creates Field w/ parameter', function () {
$field = Field::equal('another_test', 'turkey', ':test');
expect($field)
->not->toBeNull()
->fieldName->toBe('another_test')
->op->toBe(Op::Equal)
->paramName->toBe(':test')
->and($field->value)->toBe('turkey');
});
});
describe('::greater()', function () {
test('creates Field w/o parameter', function () {
$field = Field::greater('your_test', 4);
expect($field)
->not->toBeNull()
->fieldName->toBe('your_test')
->op->toBe(Op::Greater)
->paramName->toBeEmpty()
->and($field->value)->toBe(4);
});
test('creates Field w/ parameter', function () {
$field = Field::greater('more_test', 'chicken', ':value');
expect($field)
->not->toBeNull()
->fieldName->toBe('more_test')
->op->toBe(Op::Greater)
->paramName->toBe(':value')
->and($field->value)->toBe('chicken');
});
});
describe('::greaterOrEqual()', function () {
test('creates Field w/o parameter', function () {
$field = Field::greaterOrEqual('their_test', 6);
expect($field)
->not->toBeNull()
->fieldName->toBe('their_test')
->op->toBe(Op::GreaterOrEqual)
->paramName->toBeEmpty()
->and($field->value)->toBe(6);
});
test('creates Field w/ parameter', function () {
$field = Field::greaterOrEqual('greater_test', 'poultry', ':cluck');
expect($field)
->not->toBeNull()
->fieldName->toBe('greater_test')
->op->toBe(Op::GreaterOrEqual)
->paramName->toBe(':cluck')
->and($field->value)->toBe('poultry');
});
});
describe('::less()', function () {
test('creates Field w/o parameter', function () {
$field = Field::less('z', 32);
expect($field)
->not->toBeNull()
->fieldName->toBe('z')
->op->toBe(Op::Less)
->paramName->toBeEmpty()
->and($field->value)->toBe(32);
});
test('creates Field w/ parameter', function () {
$field = Field::less('additional_test', 'fowl', ':boo');
expect($field)
->not->toBeNull()
->fieldName->toBe('additional_test')
->op->toBe(Op::Less)
->paramName->toBe(':boo')
->and($field->value)->toBe('fowl');
});
});
describe('::lessOrEqual()', function () {
test('creates Field w/o parameter', function () {
$field = Field::lessOrEqual('g', 87);
expect($field)
->not->toBeNull()
->fieldName->toBe('g')
->op->toBe(Op::LessOrEqual)
->paramName->toBeEmpty()
->and($field->value)->toBe(87);
});
test('creates Field w/ parameter', function () {
$field = Field::lessOrEqual('lesser_test', 'hen', ':woo');
expect($field)
->not->toBeNull()
->fieldName->toBe('lesser_test')
->op->toBe(Op::LessOrEqual)
->paramName->toBe(':woo')
->and($field->value)->toBe('hen');
});
});
describe('::notEqual()', function () {
test('creates Field w/o parameter', function () {
$field = Field::notEqual('j', 65);
expect($field)
->not->toBeNull()
->fieldName->toBe('j')
->op->toBe(Op::NotEqual)
->paramName->toBeEmpty()
->and($field->value)->toBe(65);
});
test('creates Field w/ parameter', function () {
$field = Field::notEqual('unequal_test', 'egg', ':zoo');
expect($field)
->not->toBeNull()
->fieldName->toBe('unequal_test')
->op->toBe(Op::NotEqual)
->paramName->toBe(':zoo')
->and($field->value)->toBe('egg');
});
});
describe('::between()', function () {
test('creates Field w/o parameter', function () {
$field = Field::between('k', 'alpha', 'zed');
expect($field)
->not->toBeNull()
->fieldName->toBe('k')
->op->toBe(Op::Between)
->paramName->toBeEmpty()
->and($field->value)->toEqual(['alpha', 'zed']);
});
test('creates Field w/ parameter', function () {
$field = Field::between('between_test', 18, 49, ':count');
expect($field)
->not->toBeNull()
->fieldName->toBe('between_test')
->op->toBe(Op::Between)
->paramName->toBe(':count')
->and($field->value)->toEqual([18, 49]);
});
});
describe('::in()', function () {
test('creates Field w/o parameter', function () {
$field = Field::in('test', [1, 2, 3]);
expect($field)
->not->toBeNull()
->fieldName->toBe('test')
->op->toBe(Op::In)
->paramName->toBeEmpty()
->and($field->value)->toEqual([1, 2, 3]);
});
test('creates Field w/ parameter', function () {
$field = Field::in('unit', ['a', 'b'], ':inParam');
expect($field)
->not->toBeNull()
->fieldName->toBe('unit')
->op->toBe(Op::In)
->paramName->toBe(':inParam')
->and($field->value)->toEqual(['a', 'b']);
});
});
describe('::inArray()', function () {
test('creates Field w/o parameter', function () {
$field = Field::inArray('test', 'tbl', [1, 2, 3]);
expect($field)
->not->toBeNull()
->fieldName->toBe('test')
->op->toBe(Op::InArray)
->paramName->toBeEmpty()
->and($field->value)->toEqual(['table' => 'tbl', 'values' => [1, 2, 3]]);
});
test('creates Field w/ parameter', function () {
$field = Field::inArray('unit', 'tab', ['a', 'b'], ':inAParam');
expect($field)
->not->toBeNull()
->fieldName->toBe('unit')
->op->toBe(Op::InArray)
->paramName->toBe(':inAParam')
->and($field->value)->toEqual(['table' => 'tab', 'values' => ['a', 'b']]);
});
});
describe('::exists()', function () {
test('creates Field', function () {
$field = Field::exists('be_there');
expect($field)
->not->toBeNull()
->fieldName->toBe('be_there')
->op->toBe(Op::Exists)
->paramName->toBeEmpty()
->and($field->value)->toBeEmpty();
});
});
describe('::notExists()', function () {
test('creates Field', function () {
$field = Field::notExists('be_absent');
expect($field)
->not->toBeNull()
->fieldName->toBe('be_absent')
->op->toBe(Op::NotExists)
->paramName->toBeEmpty()
->and($field->value)->toBeEmpty();
});
});
describe('::named()', function () {
test('creates Field', function () {
$field = Field::named('the_field');
expect($field)
->not->toBeNull()
->fieldName->toBe('the_field')
->op->toBe(Op::Equal)
->value->toBeEmpty()
->and($field->value)->toBeEmpty();
});
});

View File

@ -1,18 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\Mapper\ArrayMapper;
pest()->group('unit');
describe('->map()', function () {
test('returns the given array', function () {
$result = ['one' => 2, 'three' => 4, 'eight' => 'five'];
expect(new ArrayMapper()->map($result))->toBe($result);
});
});

View File

@ -1,17 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\Mapper\CountMapper;
pest()->group('unit');
describe('->map()', function () {
test('returns item 0 in the given array', function () {
expect(new CountMapper()->map([5, 8, 10]))->toBe(5);
});
});

View File

@ -1,65 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, Field};
use BitBadger\PDODocument\Mapper\DocumentMapper;
use Test\{PjsonDocument, PjsonId};
// ** Test class hierarchy for serialization **
class DocMapSubDoc
{
public function __construct(public int $id = 0, public string $name = '') { }
}
class DocMapTestDoc
{
public function __construct(public int $id = 0, public DocMapSubDoc $subDoc = new DocMapSubDoc()) { }
}
pest()->group('unit');
describe('Constructor', function () {
test('uses "data" as the default field name', function () {
expect(new DocumentMapper(Field::class))->fieldName->toBe('data');
});
test('uses the provided field name', function () {
expect(new DocumentMapper(Field::class, 'json'))->fieldName->toBe('json');
});
});
describe('->map()', function () {
test('deserializes valid JSON', function () {
$doc = new DocumentMapper(DocMapTestDoc::class)
->map(['data' => '{"id":7,"subDoc":{"id":22,"name":"tester"}}']);
expect($doc)
->not->toBeNull()
->id->toBe(7)
->and($doc->subDoc)
->not->toBeNull()
->id->toBe(22)
->name->toBe('tester');
});
test('deserializes valid JSON [Pjson]', function () {
$doc = new DocumentMapper(PjsonDocument::class)->map(['data' => '{"id":"seven","name":"bob","num_value":8}']);
expect($doc)
->not->toBeNull()
->id->toEqual(new PjsonId('seven'))
->name->toBe('bob')
->numValue->toBe(8)
->skipped->toBeEmpty();
});
test('throws for invalid JSON', function () {
expect(fn() => new DocumentMapper(DocMapTestDoc::class)->map(['data' => 'this is not valid']))
->toThrow(DocumentException::class);
});
test('throws for invalid JSON [Pjson]', function () {
expect(fn() => new DocumentMapper(PjsonDocument::class)->map(['data' => 'not even close']))
->toThrow(DocumentException::class);
});
});

View File

@ -1,28 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Configuration, DocumentException, Mode};
use BitBadger\PDODocument\Mapper\ExistsMapper;
pest()->group('unit');
afterEach(function () { Configuration::overrideMode(null); });
describe('->map()', function () {
test('returns a boolean value from index 0 [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(new ExistsMapper()->map([false, 'nope']))->toBeFalse();
});
test('returns a number value as boolean from index 0 [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(new ExistsMapper()->map([1, 'yep']))->toBeTrue();
});
test('throws if mode is not set', function () {
expect(fn() => new ExistsMapper()->map(['0']))->toThrow(DocumentException::class);
});
});

View File

@ -1,23 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\Mapper\StringMapper;
pest()->group('unit');
describe('->map()', function () {
test('returns existing string column value', function () {
expect(new StringMapper('test_field'))->map(['test_field' => 'test_value'])->toBe('test_value');
});
test('returns string value of non-string column', function () {
expect(new StringMapper('a_number'))->map(['a_number' => 6.7])->toBe('6.7');
});
test('returns null for a missing column', function () {
expect(new StringMapper('something_else'))->map([])->toBeNull();
});
});

View File

@ -1,23 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{DocumentException, Mode};
pest()->group('unit');
describe('::deriveFromDSN()', function () {
test('derives for PostgreSQL', function () {
expect(Mode::deriveFromDSN('pgsql:Host=localhost'))->toBe(Mode::PgSQL);
})->group('postgresql');
test('derives for SQLite', function () {
expect(Mode::deriveFromDSN('sqlite:data.db'))->toBe(Mode::SQLite);
})->group('sqlite');
test('throws for other drivers', function () {
expect(fn() => Mode::deriveFromDSN('mysql:Host=localhost'))->toThrow(DocumentException::class);
});
});

View File

@ -1,47 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\Op;
pest()->group('unit');
describe('->toSQL()', function () {
test('returns "=" for Equal', function () {
expect(Op::Equal)->toSQL()->toBe('=');
});
test('returns ">" for Greater', function () {
expect(Op::Greater)->toSQL()->toBe('>');
});
test('returns ">=" for GreaterOrEqual', function () {
expect(Op::GreaterOrEqual)->toSQL()->toBe('>=');
});
test('returns "<" for Less', function () {
expect(Op::Less)->toSQL()->toBe('<');
});
test('returns "<=" for LessOrEqual', function () {
expect(Op::LessOrEqual)->toSQL()->toBe('<=');
});
test('returns "<>" for NotEqual', function () {
expect(Op::NotEqual)->toSQL()->toBe('<>');
});
test('returns "BETWEEN" for Between', function () {
expect(Op::Between)->toSQL()->toBe('BETWEEN');
});
test('returns "IN" for In', function () {
expect(Op::In)->toSQL()->toBe('IN');
});
test('returns "?|" (escaped) for InArray', function () {
expect(Op::InArray)->toSQL()->toBe('??|');
});
test('returns "IS NOT NULL" for Exists', function () {
expect(Op::Exists)->toSQL()->toBe('IS NOT NULL');
});
test('returns "IS NULL" for NotExists', function () {
expect(Op::NotExists)->toSQL()->toBe('IS NULL');
});
});

View File

@ -1,85 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Configuration, DocumentException, Field, Mode, Parameters};
use Test\{PjsonDocument, PjsonId};
pest()->group('unit');
describe('::id()', function () {
test('creates string ID parameter', function () {
expect(Parameters::id('key'))->toEqual([':id' => 'key']);
});
test('creates string from numeric ID parameter', function () {
expect(Parameters::id(7))->toEqual([':id' => '7']);
});
});
describe('::json()', function () {
test('serializes an array', function () {
expect(Parameters::json(':it', ['id' => 18, 'url' => 'https://www.unittest.com']))
->toEqual([':it' => '{"id":18,"url":"https://www.unittest.com"}']);
});
test('serializes an array w/ an empty array value', function () {
expect(Parameters::json(':it', ['id' => 18, 'urls' => []]))->toEqual([':it' => '{"id":18,"urls":[]}']);
});
test('serializes a 1-D array w/ an empty array value', function () {
expect(Parameters::json(':it', ['urls' => []]))->toEqual([':it' => '{"urls":[]}']);
});
test('serializes a stdClass instance', function () {
$obj = new stdClass();
$obj->id = 19;
$obj->url = 'https://testhere.info';
expect(Parameters::json(':it', $obj))->toEqual([':it' => '{"id":19,"url":"https://testhere.info"}']);
});
test('serializes a Pjson class instance', function () {
expect(Parameters::json(':it', new PjsonDocument(new PjsonId('999'), 'a test', 98, 'nothing')))
->toEqual([':it' => '{"id":"999","name":"a test","num_value":98}']);
});
test('serializes an array of Pjson class instances', function () {
expect(Parameters::json(':it',
['pjson' => [new PjsonDocument(new PjsonId('997'), 'another test', 94, 'nothing')]]))
->toEqual([':it' => '{"pjson":[{"id":"997","name":"another test","num_value":94}]}']);
});
});
describe('::nameFields()', function () {
test('provides missing parameter names', function () {
$named = [Field::equal('it', 17), Field::equal('also', 22, ':also'), Field::equal('other', 24)];
Parameters::nameFields($named);
expect($named)
->toHaveLength(3)
->sequence(
fn($it) => $it->paramName->toBe(':field0'),
fn($it) => $it->paramName->toBe(':also'),
fn($it) => $it->paramName->toBe(':field2'));
});
});
describe('::addFields()', function () {
test('appends to an existing parameter array', function () {
expect(Parameters::addFields([Field::equal('b', 'two', ':b'), Field::equal('z', 18, ':z')], [':a' => 1]))
->toEqual([':a' => 1, ':b' => 'two', ':z' => 18]);
});
});
describe('::fieldNames()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('generates names [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Parameters::fieldNames(':names', ['one', 'two', 'seven']))->toEqual([':names' => "{one,two,seven}"]);
})->group('postgresql');
test('generates names [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Parameters::fieldNames(':it', ['test', 'unit', 'wow']))
->toEqual([':it0' => '$.test', ':it1' => '$.unit', ':it2' => '$.wow']);
})->group('sqlite');
test('throws when mode is not set', function () {
expect(fn() => Parameters::fieldNames('', []))->toThrow(DocumentException::class);
});
});

View File

@ -1,51 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Configuration, DocumentException, Field, Mode};
use BitBadger\PDODocument\Query\Count;
pest()->group('unit');
afterEach(function () { Configuration::overrideMode(null); });
describe('::all()', function () {
test('generates the correct SQL', function () {
expect(Count::all('a_table'))->toBe('SELECT COUNT(*) FROM a_table');
});
});
describe('::byFields()', function () {
test('generates the correct SQL', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Count::byFields('somewhere', [Field::greater('errors', 10, ':errors')]))
->toBe("SELECT COUNT(*) FROM somewhere WHERE data->>'errors' > :errors");
});
});
describe('::byContains()', function () {
test('generates the correct SQL [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Count::byContains('the_table'))->toBe('SELECT COUNT(*) FROM the_table WHERE data @> :criteria');
})->group('postgresql');
test('throws an exception [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(fn () => Count::byContains(''))->toThrow(DocumentException::class);
})->group('sqlite');
});
describe('::byJsonPath()', function () {
test('generates the correct SQL [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Count::byJsonPath('a_table'))
->toBe('SELECT COUNT(*) FROM a_table WHERE jsonb_path_exists(data, :path::jsonpath)');
})->group('postgresql');
test('throws an exception [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(fn () => Count::byJsonPath(''))->toThrow(DocumentException::class);
})->group('sqlite');
});

View File

@ -1,65 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Configuration, DocumentException, DocumentIndex, Mode};
use BitBadger\PDODocument\Query\Definition;
pest()->group('unit');
describe('::ensureTable()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('generates correct SQL [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Definition::ensureTable('documents'))
->toBe('CREATE TABLE IF NOT EXISTS documents (data JSONB NOT NULL)');
})->group('postgresql');
test('generates correct SQL [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(Definition::ensureTable('dox'))->toBe('CREATE TABLE IF NOT EXISTS dox (data TEXT NOT NULL)');
})->group('sqlite');
test('throws an exception if mode is not set', function () {
expect(fn () => Definition::ensureTable(''))->toThrow(DocumentException::class);
});
});
describe('::ensureIndexOn()', function () {
test('generates correct SQL for unqualified table, single ascending field', function () {
expect(Definition::ensureIndexOn('test', 'fields', ['details']))
->toBe("CREATE INDEX IF NOT EXISTS idx_test_fields ON test ((data->>'details'))");
});
test('generates correct SQL for qualified table, multiple fields', function () {
expect(Definition::ensureIndexOn('sch.testing', 'json', ['group', 'sub_group DESC']))
->toBe('CREATE INDEX IF NOT EXISTS idx_testing_json ON sch.testing '
. "((data->>'group'), (data->>'sub_group') DESC)");
});
});
describe('::ensureKey()', function () {
test('generates correct SQL', function () {
expect(Definition::ensureKey('tbl'))
->toBe("CREATE UNIQUE INDEX IF NOT EXISTS idx_tbl_key ON tbl ((data->>'id'))");
});
});
describe('::ensureDocumentIndexOn()', function () {
afterEach(function () { Configuration::overrideMode(null); });
test('generates correct SQL for qualified table, full index [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Definition::ensureDocumentIndexOn('my.tbl', DocumentIndex::Full))
->toBe("CREATE INDEX IF NOT EXISTS idx_tbl_document ON my.tbl USING GIN (data)");
})->group('postgresql');
test('generates correct SQL for unqualified table, optimized index [PostgreSQL]', function () {
Configuration::overrideMode(Mode::PgSQL);
expect(Definition::ensureDocumentIndexOn('it', DocumentIndex::Optimized))
->toBe("CREATE INDEX IF NOT EXISTS idx_it_document ON it USING GIN (data jsonb_path_ops)");
})->group('postgresql');
test('throws an exception [SQLite]', function () {
Configuration::overrideMode(Mode::SQLite);
expect(fn () => Definition::ensureDocumentIndexOn('', DocumentIndex::Full))->toThrow(DocumentException::class);
})->group('sqlite');
});

Some files were not shown because too many files have changed in this diff Show More