Migrate docs to docfx; publish with API

This commit is contained in:
Daniel J. Summers 2025-04-20 20:48:25 -04:00
parent dd9ef52101
commit f6756d79f1
14 changed files with 796 additions and 1 deletions

BIN
bitbadger-doc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

37
docfx.json Normal file
View File

@ -0,0 +1,37 @@
{
"$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

@ -0,0 +1,164 @@
# 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"

13
docs/advanced/index.md Normal file
View File

@ -0,0 +1,13 @@
# 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"

306
docs/advanced/related.md Normal file
View File

@ -0,0 +1,306 @@
# 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"

142
docs/basic-usage.md Normal file
View File

@ -0,0 +1,142 @@
# 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
- **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` 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::between('roomNumber', 221, 240)], ['inService' => false]);
```
## Finding Documents
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
}
```
## 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` | 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"

63
docs/getting-started.md Normal file
View File

@ -0,0 +1,63 @@
# 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"

11
docs/toc.yml Normal file
View File

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

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

41
index.md Normal file
View File

@ -0,0 +1,41 @@
---
_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.0.0-blue?label=php%208.2)
](https://packagist.org/packages/bit-badger/pdo-document#v1.0.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

@ -9,7 +9,7 @@
<output>_site/api</output> <output>_site/api</output>
<cache>.phpdoc/cache</cache> <cache>.phpdoc/cache</cache>
</paths> </paths>
<version number="2.1.0"> <version number="2.0.0">
<api> <api>
<source dsn="."> <source dsn=".">
<path>src</path> <path>src</path>

4
toc.yml Normal file
View File

@ -0,0 +1,4 @@
- name: Docs
href: docs/
- name: API (v2)
href: api/