Initial SQLite development #1

Merged
danieljsummers merged 25 commits from develop into main 2024-06-08 23:58:45 +00:00
10 changed files with 313 additions and 12 deletions
Showing only changes of commit afc5d80095 - Show all commits

View File

@ -1,7 +1,8 @@
{ {
"name": "bit-badger/pdo-document", "name": "bit-badger/pdo-document",
"require": { "require": {
"netresearch/jsonmapper": "^4" "netresearch/jsonmapper": "^4",
"ext-pdo": "*"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11" "phpunit/phpunit": "^11"

6
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "38b71eefd32cb528da22dd5908fb8d02", "content-hash": "eada4b7eb6f976e0aaf0b54b92e2ca32",
"packages": [ "packages": [
{ {
"name": "netresearch/jsonmapper", "name": "netresearch/jsonmapper",
@ -1696,7 +1696,9 @@
"stability-flags": [], "stability-flags": [],
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": [], "platform": {
"ext-pdo": "*"
},
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"
} }

View File

@ -34,4 +34,19 @@ class Document
{ {
Custom::nonQuery(Query::save($tableName), Parameters::json('@data', $document), $pdo); Custom::nonQuery(Query::save($tableName), Parameters::json('@data', $document), $pdo);
} }
/**
* Update (replace) an entire document by its ID
*
* @param string $tableName The table in which the document should be updated
* @param mixed $docId The ID of the document to be updated
* @param array|object $document The document to be updated
* @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 update(string $tableName, mixed $docId, array|object $document, ?PDO $pdo = null): void
{
Custom::nonQuery(Query::update($tableName),
array_merge(Parameters::id($docId), Parameters::json('@data', $document)), $pdo);
}
} }

44
src/Exists.php Normal file
View File

@ -0,0 +1,44 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\PDODocument\Mapper\ExistsMapper;
use PDO;
/**
* Functions to determine if documents exist
*/
class Exists
{
/**
* Determine if a document exists for the given ID
*
* @param string $tableName The name of the table in which document existence should be determined
* @param mixed $docId The ID of the document whose existence should be determined
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @return bool True if the document exists, false if not
* @throws DocumentException If any is encountered
*/
public static function byId(string $tableName, mixed $docId, ?PDO $pdo = null): bool
{
return Custom::scalar(Query\Exists::byId($tableName), Parameters::id($docId), new ExistsMapper(), $pdo);
}
/**
* Determine if a document exists using a comparison on JSON fields
*
* @param string $tableName The name of the table in which document existence should be determined
* @param 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 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, ?PDO $pdo = null,
string $conjunction = 'AND'): bool
{
$namedFields = Parameters::nameFields($fields);
return Custom::scalar(Query\Exists::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new ExistsMapper(), $pdo);
}
}

78
src/Find.php Normal file
View File

@ -0,0 +1,78 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use BitBadger\PDODocument\Mapper\DocumentMapper;
/**
* Functions to find documents
*/
class Find
{
/**
* Retrieve all documents in the given table
*
* @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
* @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): DocumentList
{
return Custom::list(Query::selectFromTable($tableName), [], new DocumentMapper($className));
}
/**
* Retrieve a document by its ID (returns false if not found)
*
* @template TDoc The type of document to be retrieved
* @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 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): mixed
{
return Custom::single(Query\Find::byId($tableName), Parameters::id($docId), new DocumentMapper($className));
}
/**
* Retrieve documents via a comparison on JSON fields
*
* @template TDoc The type of document to be retrieved
* @param string $tableName The table from which documents should be retrieved
* @param array|Field[] $fields The field comparison to match
* @param class-string<TDoc> $className The name of the class to be retrieved
* @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,
string $conjunction = 'AND'): DocumentList
{
$namedFields = Parameters::nameFields($fields);
return Custom::list(Query\Find::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new DocumentMapper($className));
}
/**
* Retrieve documents via a comparison on JSON fields, returning only the first result
*
* @template TDoc The type of document to be retrieved
* @param string $tableName The table from which the document should be retrieved
* @param array|Field[] $fields The field comparison to match
* @param class-string<TDoc> $className The name of the class to be retrieved
* @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,
string $conjunction = 'AND'): mixed
{
$namedFields = Parameters::nameFields($fields);
return Custom::single(Query\Find::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, []), new DocumentMapper($className));
}
}

View File

@ -3,6 +3,7 @@
namespace BitBadger\PDODocument\Mapper; namespace BitBadger\PDODocument\Mapper;
use BitBadger\PDODocument\DocumentException; use BitBadger\PDODocument\DocumentException;
use JsonMapper;
use JsonMapper_Exception; use JsonMapper_Exception;
/** /**
@ -11,7 +12,7 @@ use JsonMapper_Exception;
* @template TDoc The type of document returned by this mapper * @template TDoc The type of document returned by this mapper
* @implements Mapper<TDoc> Provide a mapping from JSON * @implements Mapper<TDoc> Provide a mapping from JSON
*/ */
class JsonMapper implements Mapper class DocumentMapper implements Mapper
{ {
/** /**
* Constructor * Constructor
@ -35,7 +36,7 @@ class JsonMapper implements Mapper
if (is_null($json)) { if (is_null($json)) {
throw new DocumentException("Could not map document for $this->className: " . json_last_error_msg()); throw new DocumentException("Could not map document for $this->className: " . json_last_error_msg());
} }
return (new \JsonMapper())->map($json, $this->className); return (new JsonMapper())->map($json, $this->className);
} catch (JsonMapper_Exception $ex) { } catch (JsonMapper_Exception $ex) {
throw new DocumentException("Could not map document for $this->className", previous: $ex); throw new DocumentException("Could not map document for $this->className", previous: $ex);
} }

44
src/Patch.php Normal file
View File

@ -0,0 +1,44 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use PDO;
/**
* Functions to patch (partially update) documents
*/
class Patch
{
/**
* Patch a document by its ID
*
* @param string $tableName The table in which the document should be patched
* @param mixed $docId The ID of the document to be patched
* @param array|object $patch The object with which the document should be patched (will be JSON-encoded)
* @param PDO|null $pdo The database connection to use (optional; will obtain one if not provided)
* @throws DocumentException If any is encountered (database mode must be set)
*/
public static function byId(string $tableName, mixed $docId, array|object $patch, ?PDO $pdo = null): void
{
Custom::nonQuery(Query\Patch::byId($tableName),
array_merge(Parameters::id($docId), Parameters::json('@data', $patch)), $pdo);
}
/**
* Patch documents using a comparison on JSON fields
*
* @param string $tableName The table in which documents should be patched
* @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 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, array|object $patch, ?PDO $pdo = null,
string $conjunction = 'AND'): void
{
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\Patch::byFields($tableName, $namedFields, $conjunction),
Parameters::addFields($namedFields, Parameters::json('@data', $patch)), $pdo);
}
}

View File

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument\Query;
use BitBadger\PDODocument\Configuration;
use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\Field;
use BitBadger\PDODocument\Mode;
use BitBadger\PDODocument\Query;
/**
* Queries to remove fields from documents
*
* _NOTE: When using these queries to build custom functions, be aware that different databases use significantly
* different syntax. The `$parameters` passed to these functions should be run through `Parameters::fieldNames`
* function to generate them appropriately for the database currently being targeted._
*/
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 $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 DocumentException If the database mode has not been set
*/
public static function update(string $tableName, array $parameters, string $whereClause): string
{
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 $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): string
{
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 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,
string $conjunction = 'AND'): string
{
return self::update($tableName, $parameters, Query::whereByFields($fields, $conjunction));
}
}

46
src/RemoveFields.php Normal file
View File

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace BitBadger\PDODocument;
use PDO;
/**
* Functions to remove fields from documents
*/
class RemoveFields
{
/**
* Remove fields from a document by the document's ID
*
* @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 array|string[] $fieldNames The names of the fields to be removed
* @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, array $fieldNames, ?PDO $pdo = null): void
{
$nameParams = Parameters::fieldNames('@name', $fieldNames);
Custom::nonQuery(Query\RemoveFields::byId($tableName, $nameParams),
array_merge(Parameters::id($docId), $nameParams), $pdo);
}
/**
* 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 array|Field[] $fields The field comparison to match
* @param array|string[] $fieldNames The names of the fields to be removed
* @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, array $fieldNames, ?PDO $pdo = null,
string $conjunction = 'AND'): void
{
$nameParams = Parameters::fieldNames('@name', $fieldNames);
$namedFields = Parameters::nameFields($fields);
Custom::nonQuery(Query\RemoveFields::byFields($tableName, $namedFields, $nameParams, $conjunction),
Parameters::addFields($namedFields, $nameParams), $pdo);
}
}

View File

@ -4,7 +4,7 @@ namespace Test\Unit\Mapper;
use BitBadger\PDODocument\DocumentException; use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\Field; use BitBadger\PDODocument\Field;
use BitBadger\PDODocument\Mapper\JsonMapper; use BitBadger\PDODocument\Mapper\DocumentMapper;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -21,26 +21,26 @@ class TestDocument
} }
/** /**
* Unit tests for the JsonMapper class * Unit tests for the DocumentMapper class
*/ */
class JsonMapperTest extends TestCase class DocumentMapperTest extends TestCase
{ {
public function testConstructorSucceedsWithDefaultField(): void public function testConstructorSucceedsWithDefaultField(): void
{ {
$mapper = new JsonMapper(Field::class); $mapper = new DocumentMapper(Field::class);
$this->assertEquals('data', $mapper->fieldName, 'Default field name should have been "data"'); $this->assertEquals('data', $mapper->fieldName, 'Default field name should have been "data"');
} }
public function testConstructorSucceedsWithSpecifiedField(): void public function testConstructorSucceedsWithSpecifiedField(): void
{ {
$mapper = new JsonMapper(Field::class, 'json'); $mapper = new DocumentMapper(Field::class, 'json');
$this->assertEquals('json', $mapper->fieldName, 'Field name not recorded correctly'); $this->assertEquals('json', $mapper->fieldName, 'Field name not recorded correctly');
} }
#[TestDox('Map succeeds with valid JSON')] #[TestDox('Map succeeds with valid JSON')]
public function testMapSucceedsWithValidJSON(): void public function testMapSucceedsWithValidJSON(): void
{ {
$doc = (new JsonMapper(TestDocument::class))->map(['data' => '{"id":7,"subDoc":{"id":22,"name":"tester"}}']); $doc = (new DocumentMapper(TestDocument::class))->map(['data' => '{"id":7,"subDoc":{"id":22,"name":"tester"}}']);
$this->assertNotNull($doc, 'The document should not have been null'); $this->assertNotNull($doc, 'The document should not have been null');
$this->assertEquals(7, $doc->id, 'ID not filled correctly'); $this->assertEquals(7, $doc->id, 'ID not filled correctly');
$this->assertNotNull($doc->subDoc, 'The sub-document should not have been null'); $this->assertNotNull($doc->subDoc, 'The sub-document should not have been null');
@ -52,6 +52,6 @@ class JsonMapperTest extends TestCase
public function testMapFailsWithInvalidJSON(): void public function testMapFailsWithInvalidJSON(): void
{ {
$this->expectException(DocumentException::class); $this->expectException(DocumentException::class);
(new JsonMapper(TestDocument::class))->map(['data' => 'this is not valid']); (new DocumentMapper(TestDocument::class))->map(['data' => 'this is not valid']);
} }
} }