Add support, custom, and other queries

This commit is contained in:
Daniel J. Summers 2024-06-03 23:10:12 -04:00
parent 98bfceb7c9
commit b705130624
13 changed files with 576 additions and 8 deletions

43
src/Count.php Normal file
View File

@ -0,0 +1,43 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\PDODocument\Mapper\CountMapper;
use PDO;
/**
* Functions to count documents
*/
class Count
{
/**
* Count all documents in a table
*
* @param string $tableName The name of the table in which documents should be counted
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @return int The count of documents in the table
* @throws DocumentException If one is encountered
*/
public static function all(string $tableName, ?PDO $pdo = null): int
{
return Custom::scalar(Query\Count::all($tableName), [], new CountMapper(), $pdo);
}
/**
* Count matching documents using a comparison on JSON fields
*
* @param string $tableName The name of the table in which documents should be counted
* @param array|Field[] $fields The field comparison to match
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @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, ?PDO $pdo = null,
string $conjunction = 'AND'): int
{
$namedFields = Parameters::nameFields($fields);
return Custom::scalar(Query\Count::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new CountMapper(), $pdo);
}
}

117
src/Custom.php Normal file
View File

@ -0,0 +1,117 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\PDODocument\Mapper\Mapper;
use PDO;
use PDOStatement;
/**
* Functions to execute custom queries
*/
class Custom
{
/**
* Prepare a query for execution and run it
*
* @param string $query The query to be run
* @param array $parameters The parameters for the query
* @param PDO $pdo The database connection on which the query should be run
* @return PDOStatement The result of executing the query
*/
public static function runQuery(string $query, array $parameters, PDO $pdo): PDOStatement
{
$debug = defined('PDO_DOC_DEBUG_SQL');
$stmt = $pdo->prepare($query);
foreach ($parameters as $key => $value) {
if ($debug) echo "<pre>Binding $value to $key\n</pre>";
$stmt->bindValue($key, $value);
}
if ($debug) echo '<pre>SQL: ' . $stmt->queryString . '</pre>';
$stmt->execute();
return $stmt;
}
/**
* Execute a query that returns a list of results (lazy)
*
* @template TDoc The domain type of the document to retrieve
* @param string $query The query to be executed
* @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
*/
public static function list(string $query, array $parameters, Mapper $mapper): DocumentList
{
return DocumentList::create($query, $parameters, $mapper);
}
/**
* Execute a query that returns an array of results (eager)
*
* @template TDoc The domain type of the document to retrieve
* @param string $query The query to be executed
* @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());
}
/**
* 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 $parameters Parameters to use in executing the query
* @param Mapper<TDoc> $mapper Mapper to deserialize the result
* @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): mixed
{
return empty($results = self::array("$query LIMIT 1", $parameters, $mapper)) ? false : $results[0];
}
/**
* Execute a query that does not return a value
*
* @param string $query The query to execute
* @param array $parameters Parameters to use in executing the query
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function nonQuery(string $query, array $parameters, ?PDO $pdo = null): void
{
$stmt = self::runQuery($query, $parameters, $pdo ?? Configuration::dbConn());
if ($stmt->errorCode()) throw new DocumentException('Error executing command: ' . $stmt->errorCode());
}
/**
* Execute a query that returns a scalar value
*
* @template T The scalar type to return
* @param string $query The query to retrieve the value
* @param array $parameters Parameters to use in executing the query
* @param Mapper<T> $mapper The mapper to obtain the result
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @return mixed|false|T The scalar value if found, false if not
* @throws DocumentException If any is encountered
*/
public static function scalar(string $query, array $parameters, Mapper $mapper, ?PDO $pdo = null): mixed
{
$stmt = self::runQuery($query, $parameters, $pdo ?? Configuration::dbConn());
if ($stmt->errorCode()) {
throw new DocumentException('Error retrieving scalar value: ' . $stmt->errorCode());
}
if ($stmt->rowCount() > 0) {
$first = $stmt->fetch(PDO::FETCH_NUM);
return $first ? $mapper->map($first) : false;
}
return false;
}
}

38
src/Definition.php Normal file
View File

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use PDO;
/**
* Functions to create tables and indexes
*/
class Definition
{
/**
* Ensure a document table exists
*
* @param string $name The name of the table to be created if it does not exist
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function ensureTable(string $name, ?PDO $pdo = null): void
{
Custom::nonQuery(Query\Definition::ensureTable($name), [], $pdo);
Custom::nonQuery(Query\Definition::ensureKey($name), [], $pdo);
}
/**
* Ensure a field index exists on a document table
*
* @param string $tableName The name of the table which should be indexed
* @param string $indexName The name of the index
* @param array $fields Fields which should be a part of this index
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function ensureFieldIndex(string $tableName, string $indexName, array $fields, ?PDO $pdo = null): void
{
Custom::nonQuery(Query\Definition::ensureIndexOn($tableName, $indexName, $fields), [], $pdo);
}
}

41
src/Delete.php Normal file
View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use PDO;
/**
* Functions to delete documents
*/
class Delete
{
/**
* Delete a document by its ID
*
* @param string $tableName The table from which the document should be deleted
* @param mixed $docId The ID of the document to be deleted
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function byId(string $tableName, mixed $docId, ?PDO $pdo = null): void
{
Custom::nonQuery(Query\Delete::byId($tableName), Parameters::id($docId), $pdo);
}
/**
* Delete documents by matching a comparison on JSON fields
*
* @param string $tableName The table from which documents should be deleted
* @param array|Field[] $fields The field comparison to match
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @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, ?PDO $pdo = null,
string $conjunction = 'AND'): void
{
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\Delete::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), $pdo);
}
}

37
src/Document.php Normal file
View File

@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use PDO;
/**
* Functions that apply at a whole document level
*/
class Document
{
/**
* Insert a new document
*
* @param string $tableName The name of the table into which the document should be inserted
* @param array|object $document The document to be inserted
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function insert(string $tableName, array|object $document, ?PDO $pdo = null): void
{
Custom::nonQuery(Query::insert($tableName), Parameters::json('@data', $document), $pdo);
}
/**
* 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 array|object $document The document to be saved
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered
*/
public static function save(string $tableName, array|object $document, ?PDO $pdo = null): void
{
Custom::nonQuery(Query::save($tableName), Parameters::json('@data', $document), $pdo);
}
}

View File

@ -2,7 +2,10 @@
namespace BitBadger\PDODocument;
use BitBadger\PDODocument\Mapper\Mapper;
use Generator;
use PDO;
use PDOStatement;
/**
* A lazy iterator of results in a list; implementations will create new connections to the database and close/dispose
@ -10,14 +13,16 @@ use Generator;
*
* @template TDoc The domain class for items returned by this list
*/
interface DocumentList
readonly class DocumentList
{
/**
* The items from the query result
* Constructor
*
* @return Generator<TDoc> The query results as a lazily-iterated generator
* @param PDO $pdo The database connection against which the query was opened
* @param PDOStatement $result The result of the query
* @param Mapper<TDoc> $mapper The mapper to deserialize JSON
*/
public function items(): Generator;
private function __construct(private PDO $pdo, private PDOStatement $result, private Mapper $mapper) { }
/**
* Construct a new document list
@ -25,12 +30,28 @@ interface DocumentList
* @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 `DocumentList`-implementing instance
* @return static The document list instance
* @throws DocumentException If any is encountered
*/
public static function create(string $query, array $parameters, Mapper $mapper): static;
public static function create(string $query, array $parameters, Mapper $mapper): static
{
$pdo = Configuration::dbConn();
$stmt = Custom::runQuery($query, $parameters, $pdo);
if ($stmt->errorCode()) {
throw new DocumentException('Error retrieving data: ' . $stmt->errorCode());
}
return new static($pdo, $stmt, $mapper);
}
/**
* Clean up database connection resources
* The items from the query result
*
* @return Generator<TDoc> The items from the document list
*/
public function __destruct();
public function items(): Generator
{
if ($this->result) {
while ($row = $this->result->fetch(PDO::FETCH_ASSOC)) yield $this->mapper->map($row);
}
}
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
/**
* A mapper that returns the integer value of the first item in the results
*/
class CountMapper implements Mapper
{
/**
* @inheritDoc
*/
public function map(array $result): int
{
return (int) $result[0];
}
}

View File

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Mapper;
use BitBadger\PDODocument\Configuration;
use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\Mode;
/**
* Map an EXISTS result to a boolean value
*/
class ExistsMapper implements Mapper
{
/**
* @inheritDoc
* @throws DocumentException If the database mode has not been set
*/
public function map(array $result): bool
{
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'),
};
}
}

81
src/Parameters.php Normal file
View File

@ -0,0 +1,81 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
/**
* Functions to create parameters for queries
*/
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[] An associative array with an "@id" parameter/value pair
*/
public static function id(mixed $key): array
{
return ['@id' => is_string($key) ? $key : "$key"];
}
/**
* Create a parameter with a JSON value
*
* @param string $name The name of the JSON parameter
* @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
{
return [$name => json_encode($document, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
}
/**
* Fill in parameter names for any fields missing one
*
* @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): array
{
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 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
{
return array_reduce($fields, fn($carry, $item) => $item->appendParameter($carry), $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 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
{
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

@ -12,11 +12,13 @@ use PHPUnit\Framework\TestCase;
*/
class ConfigurationTest extends TestCase
{
#[TestDox('ID field default succeeds')]
public function testIdFieldDefaultSucceeds(): void
{
$this->assertEquals('id', Configuration::$idField, 'Default ID field should be "id"');
}
#[TestDox('ID field change succeeds')]
public function testIdFieldChangeSucceeds()
{
try {

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace Test\Unit\Mapper;
use BitBadger\PDODocument\Mapper\CountMapper;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the CountMapper class
*/
class CountMapperTest extends TestCase
{
public function testMapSucceeds(): void
{
$this->assertEquals(5, (new CountMapper())->map([5, 8, 10]), 'Count not correct');
}
}

View File

@ -0,0 +1,45 @@
<?php declare(strict_types=1);
namespace Test\Unit\Mapper;
use BitBadger\PDODocument\Configuration;
use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\Mapper\ExistsMapper;
use BitBadger\PDODocument\Mode;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the ExistsMapper class
*/
class ExistsMapperTest extends TestCase
{
#[TestDox('Map succeeds for PostgreSQL')]
public function testMapSucceedsForPostgreSQL(): void
{
try {
Configuration::$mode = Mode::PgSQL;
$this->assertFalse((new ExistsMapper())->map([false, 'nope']), 'Result should have been false');
} finally {
Configuration::$mode = null;
}
}
#[TestDox('Map succeeds for SQLite')]
public function testMapSucceedsForSQLite(): void
{
try {
Configuration::$mode = Mode::SQLite;
$this->assertTrue((new ExistsMapper())->map([1, 'yep']), 'Result should have been true');
} finally {
Configuration::$mode = null;
}
}
public function testMapFailsWhenModeNotSet(): void
{
$this->expectException(DocumentException::class);
Configuration::$mode = null;
(new ExistsMapper())->map(['0']);
}
}

View File

@ -0,0 +1,83 @@
<?php declare(strict_types=1);
namespace Tests\Unit;
use BitBadger\PDODocument\Configuration;
use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\Field;
use BitBadger\PDODocument\Mode;
use BitBadger\PDODocument\Parameters;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the Parameters class
*/
class ParametersTest extends TestCase
{
#[TestDox('ID succeeds with string')]
public function testIdSucceedsWithString(): void
{
$this->assertEquals(['@id' => 'key'], Parameters::id('key'), 'ID parameter not constructed correctly');
}
#[TestDox('ID succeeds with non string')]
public function testIdSucceedsWithNonString(): void
{
$this->assertEquals(['@id' => '7'], Parameters::id(7), 'ID parameter not constructed correctly');
}
public function testJsonSucceeds(): void
{
$this->assertEquals(['@it' => '{"id":18,"url":"https://www.unittest.com"}'],
Parameters::json('@it', ['id' => 18, 'url' => 'https://www.unittest.com']),
'JSON parameter not constructed correctly');
}
public function testNameFieldsSucceeds(): void
{
$named = Parameters::nameFields([Field::EQ('it', 17), Field::EQ('also', 22, '@also'), Field::EQ('other', 24)]);
$this->assertCount(3, $named, 'There should be 3 parameters in the array');
$this->assertEquals('@field0', $named[0]->paramName, 'Parameter 1 not named correctly');
$this->assertEquals('@also', $named[1]->paramName, 'Parameter 2 not named correctly');
$this->assertEquals('@field2', $named[2]->paramName, 'Parameter 3 not named correctly');
}
public function testAddFieldsSucceeds(): void
{
$this->assertEquals(['@a' => 1, '@b' => 'two', '@z' => 18],
Parameters::addFields([Field::EQ('b', 'two', '@b'), Field::EQ('z', 18, '@z')], ['@a' => 1]),
'Field parameters not added correctly');
}
#[TestDox('Field names succeeds for PostgreSQL')]
public function testFieldNamesSucceedsForPostgreSQL(): void
{
try {
Configuration::$mode = Mode::PgSQL;
$this->assertEquals(['@names' => "ARRAY['one','two','seven']"],
Parameters::fieldNames('@names', ['one', 'two', 'seven']), 'Field name parameters not correct');
} finally {
Configuration::$mode = null;
}
}
#[TestDox('Field names succeeds for SQLite')]
public function testFieldNamesSucceedsForSQLite(): void
{
try {
Configuration::$mode = Mode::SQLite;
$this->assertEquals(['@it0' => 'test', '@it1' => 'unit', '@it2' => 'wow'],
Parameters::fieldNames('@it', ['test', 'unit', 'wow']), 'Field name parameters not correct');
} finally {
Configuration::$mode = null;
}
}
public function testFieldNamesFailsWhenModeNotSet(): void
{
$this->expectException(DocumentException::class);
Configuration::$mode = null;
Parameters::fieldNames('', []);
}
}