From b70513062452abd8527b60212bffa1c676ab571b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 3 Jun 2024 23:10:12 -0400 Subject: [PATCH] Add support, custom, and other queries --- src/Count.php | 43 +++++++++ src/Custom.php | 117 +++++++++++++++++++++++++ src/Definition.php | 38 ++++++++ src/Delete.php | 41 +++++++++ src/Document.php | 37 ++++++++ src/DocumentList.php | 37 ++++++-- src/Mapper/CountMapper.php | 17 ++++ src/Mapper/ExistsMapper.php | 26 ++++++ src/Parameters.php | 81 +++++++++++++++++ tests/unit/ConfigurationTest.php | 2 + tests/unit/Mapper/CountMapperTest.php | 17 ++++ tests/unit/Mapper/ExistsMapperTest.php | 45 ++++++++++ tests/unit/ParametersTest.php | 83 ++++++++++++++++++ 13 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 src/Count.php create mode 100644 src/Custom.php create mode 100644 src/Definition.php create mode 100644 src/Delete.php create mode 100644 src/Document.php create mode 100644 src/Mapper/CountMapper.php create mode 100644 src/Mapper/ExistsMapper.php create mode 100644 src/Parameters.php create mode 100644 tests/unit/Mapper/CountMapperTest.php create mode 100644 tests/unit/Mapper/ExistsMapperTest.php create mode 100644 tests/unit/ParametersTest.php diff --git a/src/Count.php b/src/Count.php new file mode 100644 index 0000000..ca945b2 --- /dev/null +++ b/src/Count.php @@ -0,0 +1,43 @@ +prepare($query); + foreach ($parameters as $key => $value) { + if ($debug) echo "
Binding $value to $key\n
"; + $stmt->bindValue($key, $value); + } + if ($debug) echo '
SQL: ' . $stmt->queryString . '
'; + $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 $mapper Mapper to deserialize the result + * @return DocumentList 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 $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 $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 $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; + } +} diff --git a/src/Definition.php b/src/Definition.php new file mode 100644 index 0000000..85b0169 --- /dev/null +++ b/src/Definition.php @@ -0,0 +1,38 @@ + 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 $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 $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 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); + } + } } diff --git a/src/Mapper/CountMapper.php b/src/Mapper/CountMapper.php new file mode 100644 index 0000000..a2922a7 --- /dev/null +++ b/src/Mapper/CountMapper.php @@ -0,0 +1,17 @@ + (bool)$result[0], + Mode::SQLite => (int)$result[0] > 0, + default => throw new DocumentException('Database mode not set; cannot map existence result'), + }; + } +} diff --git a/src/Parameters.php b/src/Parameters.php new file mode 100644 index 0000000..592b135 --- /dev/null +++ b/src/Parameters.php @@ -0,0 +1,81 @@ + 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'); + } + } +} diff --git a/tests/unit/ConfigurationTest.php b/tests/unit/ConfigurationTest.php index 2efbf6d..6ff0c09 100644 --- a/tests/unit/ConfigurationTest.php +++ b/tests/unit/ConfigurationTest.php @@ -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 { diff --git a/tests/unit/Mapper/CountMapperTest.php b/tests/unit/Mapper/CountMapperTest.php new file mode 100644 index 0000000..130763a --- /dev/null +++ b/tests/unit/Mapper/CountMapperTest.php @@ -0,0 +1,17 @@ +assertEquals(5, (new CountMapper())->map([5, 8, 10]), 'Count not correct'); + } +} diff --git a/tests/unit/Mapper/ExistsMapperTest.php b/tests/unit/Mapper/ExistsMapperTest.php new file mode 100644 index 0000000..ce68908 --- /dev/null +++ b/tests/unit/Mapper/ExistsMapperTest.php @@ -0,0 +1,45 @@ +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']); + } +} diff --git a/tests/unit/ParametersTest.php b/tests/unit/ParametersTest.php new file mode 100644 index 0000000..1a0627c --- /dev/null +++ b/tests/unit/ParametersTest.php @@ -0,0 +1,83 @@ +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('', []); + } +}