diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml
new file mode 100644
index 0000000..2b10cb6
--- /dev/null
+++ b/.github/workflows/tests-unit.yml
@@ -0,0 +1,63 @@
+name: Run Unit Tests
+
+on:
+ pull_request:
+ types:
+ - "opened"
+ - "reopened"
+ - "synchronize"
+ branches:
+ - master
+
+jobs:
+ phpunit_test:
+ name: "Unit Test"
+ runs-on: ubuntu-22.04
+ env:
+ XDEBUG_MODE: coverage
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Docker Compose Up
+ run: docker compose up -d
+
+ - name: Docker Compose PS
+ run: docker compose ps
+
+ - name: Run Composer Install
+ run: docker exec persistables-php-1 composer install
+
+ - name: Run Unit Tests
+ run: docker exec persistables-php-1 vendor/bin/phpunit
+
+ - name: Install Xmllint
+ run: sudo apt-get update && sudo apt-get install -y libxml2-utils
+
+ - name: Check Coverage Threshold
+ run: |
+ FILE=clover/clover.xml
+
+ if [ ! -f "$FILE" ]; then
+ echo "❌ Coverage report not found at $FILE"
+ exit 1
+ fi
+
+ COVERED=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' "$FILE")
+ TOTAL=$(xmllint --xpath 'string(//project/metrics/@statements)' "$FILE")
+
+ if [ -z "$COVERED" ] || [ -z "$TOTAL" ] || [ "$TOTAL" -eq 0 ]; then
+ echo "⚠️ Error parsing coverage"
+ exit 1
+ fi
+ PERCENT=$(awk "BEGIN {printf \"%.2f\", ($COVERED/$TOTAL) * 100 }")
+
+ # Check threshold
+ THRESHOLD=90
+ if (( $(awk "BEGIN {print ($PERCENT < $THRESHOLD)}") )); then
+ echo "❌ Coverage $PERCENT% is below required $THRESHOLD%"
+ exit 1
+ else
+ echo "✅ Coverage $PERCENT% meets threshold"
+ fi
+
diff --git a/.gitignore b/.gitignore
index e63071b..3c60b64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,7 +46,8 @@ Thumbs.db
*.cache
# Coverage reports
-coverage/
+/tests/coverage/
+/clover/
# Build artifacts
/build/
diff --git a/README.md b/README.md
index 3c9e912..bec65f4 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
# Subtext\Persistables
+
A lightweight PHP library designed to abstract and unify the persistence of
domain objects across SQL databases. Inspired by the principles of ORMs, but
@@ -23,10 +24,12 @@ namespace Subtext\Persistables;
#[Table(name: 'users', primaryKey: 'userId')]
class User extends Persistable
{
- #[Column(name: 'user_id', primary: true)]
+ #[Column(name: 'user_id')]
protected ?int $userId = null;
+
#[Column(name: 'user_name')]
protected ?string $userName = null;
+
#[Column(name: 'email_address')]
protected ?string $email = null;
@@ -74,12 +77,6 @@ class User extends Persistable
'userName' => $this->getUserName(),
'email' => $this->getEmail(),
];
- }
-
- public function getPersistables(): ?Collection
- {
- return null;
- }
-
+ }
}
```
diff --git a/debug.xml b/debug.xml
new file mode 100644
index 0000000..ba39c74
--- /dev/null
+++ b/debug.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests/unit
+
+
+
+
+ src
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index dea1693..9bdaa90 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,6 +11,7 @@ services:
DB_HOST: ${DB_HOST}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
+ XDEBUG_MODE: ${XDEBUG_MODE}
volumes:
- ./:/app
working_dir: /app
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..bf54bd4
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ tests/unit
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Collection.php b/src/Collection.php
index 33509d5..e6e309d 100644
--- a/src/Collection.php
+++ b/src/Collection.php
@@ -17,7 +17,7 @@ abstract public function getEntityClass(): string;
protected function validate(mixed $value): void
{
- if (!is_object($value) || !($value::class === $this->getEntityClass())) {
+ if (!is_object($value) || !(is_a($value, $this->getEntityClass()))) {
throw new InvalidArgumentException(sprintf(
"Value must be an instance of %s",
$this->getEntityClass()
diff --git a/src/Container.php b/src/Container.php
new file mode 100644
index 0000000..80b9f71
--- /dev/null
+++ b/src/Container.php
@@ -0,0 +1,23 @@
+fn) {
+ $pdo = ($this->fn)(
+ $this->database,
+ $this->hostname,
+ $this->username,
+ $this->password,
+ $this->driver,
+ $this->charset,
+ );
+ }
+ return $pdo;
+ }
/**
- * @return PDO
- * @throws TypeError if PDO is null
+ * Currently only mysql is supported. Other database drivers will be added
+ * in the future.
+ *
+ * @return SqlGenerator
*/
- public function getPdo(): PDO;
-
- public function getSqlGenerator(): SqlGenerator;
+ public function getSqlGenerator(): SqlGenerator
+ {
+ return MySqlGenerator::getInstance();
+ }
}
diff --git a/src/Databases/Connections/MySql.php b/src/Databases/Connections/MySql.php
deleted file mode 100644
index bb191d5..0000000
--- a/src/Databases/Connections/MySql.php
+++ /dev/null
@@ -1,63 +0,0 @@
-pdo = $pdo;
- $this->generator = MySqlGenerator::getInstance();
- }
-
- /**
- * Database credentials must be applied as environment variables.
- *
- * @return self
- */
- public static function getInstance(): self
- {
- try {
- if (self::$instance === null) {
- $name = getenv('DB_NAME');
- $host = getenv('DB_HOST');
- $user = getenv('DB_USER');
- $pass = getenv('DB_PASS');
- self::$instance = new self(new PDO(
- "mysql:dbname={$name};host={$host};charset=utf8mb4",
- $user,
- $pass,
- ));
- }
- } catch (PDOException $e) {
- throw new RuntimeException(
- 'The MySql connection could not be established.',
- $e->getCode(),
- $e
- );
- } finally {
- return self::$instance;
- }
- }
-
- public function getPdo(): PDO
- {
- return $this->pdo;
- }
-
- public function getSqlGenerator(): SqlGenerator
- {
- return $this->generator;
- }
-}
diff --git a/src/Databases/Error.php b/src/Databases/Error.php
new file mode 100644
index 0000000..7f3ad69
--- /dev/null
+++ b/src/Databases/Error.php
@@ -0,0 +1,13 @@
+meta = new Collection();
}
- public static function getInstance(): self
+ public static function getInstance(bool $new = false): self
{
- if (self::$instance === null) {
+ if (self::$instance === null || $new) {
self::$instance = new self();
}
return self::$instance;
@@ -185,11 +184,6 @@ private function parseAccessor(
private function inferAccessorName(string $property, string $class, string $type = 'get'): ?string
{
- if (!in_array($type, self::ACCESSOR)) {
- throw new InvalidArgumentException(
- 'Type argument must be one of ' . implode(', ', self::ACCESSOR) . '.'
- );
- }
$method = $type . ucfirst($property);
$this->validateMethod($class, $method);
return $method;
@@ -209,7 +203,7 @@ private function validateMethod(string $class, string $method): void
private function validateClass(string $class): void
{
if (class_exists($class)) {
- if (is_subclass_of($class, Persistable::class) || is_subclass_of($class, Collection::class)) {
+ if (is_subclass_of($class, Persistable::class) || is_subclass_of($class, PersistableCollection::class)) {
return;
}
}
diff --git a/src/Databases/Sql.php b/src/Databases/Sql.php
index 003ddcd..f5f190e 100644
--- a/src/Databases/Sql.php
+++ b/src/Databases/Sql.php
@@ -292,24 +292,23 @@ public function execute(string $sql, array $data = []): bool
*/
public function executeTransaction(Collection $commands): void
{
- $success = true;
+ $success = false;
+ $this->pdo->beginTransaction();
foreach ($commands as $command) {
- $this->pdo->beginTransaction();
- try {
- $stmt = $this->getPreparedStatement($command->getQuery());
- $success = $this->executeStatement($stmt, $command->getData());
- } catch (Throwable $e) {
- $success = false;
+ $stmt = $this->getPreparedStatement($command->getQuery());
+ $success = $this->executeStatement($stmt, $command->getData());
+ if (!$success) {
+ break;
}
- if ($success) {
- try {
- $this->pdo->commit();
- } catch (Throwable $e) {
- $this->pdo->rollback();
- }
- } else {
+ }
+ if ($success) {
+ try {
+ $this->pdo->commit();
+ } catch (PDOException $e) {
$this->pdo->rollback();
}
+ } else {
+ $this->pdo->rollback();
}
}
@@ -411,7 +410,7 @@ private function getResultFromDatabase(
default:
$result = false;
}
- if ($result === false) {
+ if (($result ?? false) === false) {
$result = $this->getDefaultResult($type);
}
} catch (PDOException $e) {
@@ -454,31 +453,28 @@ private function getDefaultResult(int $type): string|array
private function executeStatement(PDOStatement $stmt, array $params): bool
{
try {
- $success = true;
+ $success = false;
if ($this->hasTypeData($params)) {
foreach ($params as $key => $obj) {
if (is_object($obj)) {
$stmt->bindValue($key, $obj->value, $obj->type);
} else {
+ // defaults to string type
$stmt->bindValue($key, $obj);
}
}
- $result = $stmt->execute();
+ $success = $stmt->execute();
} else {
- $result = $stmt->execute($params);
+ $success = $stmt->execute($params);
}
- if ($result === false) {
- $success = false;
+ if ($success === false) {
$errorInfo = $stmt->errorInfo();
- array_push($this->errors, [
- 'msg' => $errorInfo[2],
- 'code' => $errorInfo[0],
- 'info' => $errorInfo[1],
- ]);
- if (!defined('TEST_MODE')) {
- error_log('PDO_FAILED_QUERY ' . $errorInfo[2]);
- error_log('PDO_FAILED_QUERY ' . $_SERVER['SCRIPT_FILENAME']);
- error_log('PDO_FAILED_QUERY ' . $stmt->queryString);
+ if (!empty($errorInfo)) {
+ $this->errors[] = new Error(
+ $errorInfo[2] ?? '',
+ $errorInfo[0] ?? 500,
+ $errorInfo[1] ?? ''
+ );
}
}
} catch (PDOException $e) {
@@ -515,10 +511,10 @@ private function hasTypeData(array $params): bool
*/
private function recordError(Throwable $e): void
{
- array_push($this->errors, [
- 'msg' => $e->getMessage(),
- 'code' => $e->getCode(),
- 'info' => $e->getTraceAsString(),
- ]);
+ $this->errors[] = new Error(
+ $e->getMessage(),
+ $e->getCode(),
+ $e->getTraceAsString()
+ );
}
}
diff --git a/src/Databases/SqlGenerators/MySqlGenerator.php b/src/Databases/SqlGenerators/MySqlGenerator.php
index df20078..6ca5e57 100644
--- a/src/Databases/SqlGenerators/MySqlGenerator.php
+++ b/src/Databases/SqlGenerators/MySqlGenerator.php
@@ -4,7 +4,9 @@
use Subtext\Collections\Text;
use Subtext\Persistables\Databases\Attributes\Column;
+use Subtext\Persistables\Databases\Attributes\Columns;
use Subtext\Persistables\Databases\Attributes\Joins\Collection;
+use Subtext\Persistables\Databases\Attributes\Table;
use Subtext\Persistables\Databases\Meta;
use Subtext\Persistables\Databases\SqlGenerator;
use Subtext\Persistables\Modifications;
@@ -12,10 +14,10 @@
class MySqlGenerator implements SqlGenerator
{
private static ?self $instance = null;
- public const string SQL_SELECT = 'SELECT %s FROM %s WHERE %s';
- public const string SQL_INSERT = 'INSERT INTO `%s` (%s) VALUES XXX';
- public const string SQL_UPDATE = 'UPDATE `%s` SET %s WHERE `%s` = ?';
- public const string SQL_DELETE = 'DELETE FROM `%s` WHERE `%s` ';
+ public const string SQL_SELECT = "SELECT %s\nFROM %s\nWHERE %s";
+ public const string SQL_INSERT = "INSERT INTO `%s` (%s)\nVALUES XXX";
+ public const string SQL_UPDATE = "UPDATE `%s` SET %s\nWHERE `%s` = ?";
+ public const string SQL_DELETE = "DELETE FROM `%s`\nWHERE `%s`";
public const string SQL_COLUMN = '`%s`.`%s`';
private function __construct()
@@ -43,9 +45,9 @@ public function getSelectQuery(Meta $meta, ?string $clause = null): string
$fields = new Text();
foreach ($columns as $property => $column) {
$tableName = $column->table ?? $table->name;
- if (($property === $column->name) || is_null($column->name)) {
+ if ($property === $column->name || is_null($column->name)) {
$fields->append(
- $this->formatColumnName($tableName, $column->name)
+ $this->formatColumnName($tableName, $column->name ?? $property)
);
} else {
$fields->append($this->formatColumnName(
@@ -59,30 +61,38 @@ public function getSelectQuery(Meta $meta, ?string $clause = null): string
self::SQL_SELECT,
$fields->concat(",\n"),
sprintf(
- "`%s`\n%s",
+ "`%s`%s",
$table->name,
- $joins ? $this->getJoinClauses($joins, $table->name)->concat("\n") : ''
+ $joins ? "\n" . $this->getJoinClauses($joins, $table->name)->concat("\n") : ''
),
- sprintf('`%s` = ?', $table->primaryKey)
+ $clause ?? sprintf(self::SQL_COLUMN . ' = ?', $table->name, $table->primaryKey)
);
}
public function getInsertQuery(Meta $meta, int $rows = 1): string
{
$table = $meta->getTable();
- $columns = $meta->getColumns()->filter(function (Column $column) {
- return $column->readonly === false;
- });
+ $columns = new Columns\Collection();
+ foreach ($meta->getColumns() as $property => $column) {
+ if ($this->isNotReadOnlyAndNotPrimaryKey($property, $column, $table)) {
+ $columns->set($property, $column);
+ }
+ }
+ unset($column);
+ $names = [];
+ foreach ($columns as $property => $column) {
+ $names[] = sprintf('`%s`', $column->name ?? $property);
+ }
return $this->formatInsert(
sprintf(
self::SQL_INSERT,
$table->name,
- trim(implode(', ', $columns->getKeys()))
+ trim(implode(', ', $names))
),
$rows,
$columns->count()
) . sprintf(
- ' ON DUPLICATE KEY UPDATE %s = LAST_INSERT_ID(%s)',
+ "\n" . 'ON DUPLICATE KEY UPDATE `%s` = LAST_INSERT_ID(`%s`)',
$table->primaryKey,
$table->primaryKey
);
@@ -122,11 +132,11 @@ public function getDeleteQuery(Meta $meta, int $count = 1): string
$table = $meta->getTable();
$query = sprintf(self::SQL_DELETE, $table->name, $table->primaryKey);
if ($count === 1) {
- $query .= '= ?';
+ $query .= ' = ?';
} else {
- $query .= 'IN (XXX)';
+ $query = $this->formatIn($query . ' IN (XXX)', $count);
}
- return $this->formatIn($query, $count);
+ return $query;
}
/**
@@ -229,4 +239,13 @@ private function formatColumnName(
}
return $field;
}
+
+ private function isNotReadOnlyAndNotPrimaryKey(
+ string $property,
+ Column $column,
+ Table $table
+ ): bool {
+ $notPk = ($column->primary === false && $property !== $table->primaryKey);
+ return ($column->readonly === false) && $notPk;
+ }
}
diff --git a/src/Factory.php b/src/Factory.php
index 0c4f3cd..13cdf37 100644
--- a/src/Factory.php
+++ b/src/Factory.php
@@ -2,10 +2,12 @@
namespace Subtext\Persistables;
+use Closure;
use InvalidArgumentException;
use PDO;
use ReflectionException;
use RuntimeException;
+use Subtext\Persistables\Databases\Attributes\Column;
use Subtext\Persistables\Databases\Attributes\Entities;
use Subtext\Persistables\Databases\Attributes\Entity;
use Subtext\Persistables\Databases\Attributes\PersistOrder;
@@ -15,12 +17,12 @@
class Factory
{
protected Sql $db;
- private Meta\Factory $meta;
+ protected Meta\Factory $meta;
- public function __construct(Sql $db)
+ public function __construct(Sql $db, Meta\Factory $meta)
{
$this->db = $db;
- $this->meta = Meta\Factory::getInstance();
+ $this->meta = $meta;
}
/**
@@ -31,6 +33,7 @@ public function __construct(Sql $db)
*
* @return Persistable|null
* @throws ReflectionException
+ * @throws InvalidArgumentException
*/
public function getEntityByPrimaryKey(string $entity, mixed $primaryKeyValue): ?Persistable
{
@@ -39,7 +42,7 @@ public function getEntityByPrimaryKey(string $entity, mixed $primaryKeyValue): ?
'Entity must be a class which implements Persistable.'
);
}
- $meta = $this->getMeta($entity);
+ $meta = $this->meta->get($entity);
$result = $this->db->getQueryRow(
$this->db->getSelectQuery($meta),
[$primaryKeyValue],
@@ -88,8 +91,8 @@ public function getEntityCollection(
/**
* Saves an object's state to the database by generating sql queries across
* all tables used in populating the object data. This is a recursive method
- * which uses the getPersistables method of the Persistable class to
- * recursively apply the database logic to other embedded objects.
+ * which uses the {@see Entity} attribute metadata of the Persistable class
+ * to recursively apply the database logic to other embedded objects.
*
* @param Persistable|Collection $persistable
*
@@ -140,9 +143,12 @@ public function desist(Persistable|Collection $persistable): void
protected function isDbInsert(Persistable|Collection $persistable): bool
{
if ($persistable instanceof Collection) {
- $isInsert = $persistable->reduce([$this, 'isPersistableInsert'], false);
+ $isInsert = $persistable->reduce(
+ Closure::fromCallable([$this, 'isPersistableInsert']),
+ true
+ );
} else {
- $isInsert = $this->isPersistableInsert($persistable);
+ $isInsert = $this->isPersistableInsert(true, $persistable);
}
return $isInsert;
}
@@ -158,9 +164,12 @@ protected function isDbInsert(Persistable|Collection $persistable): bool
protected function isDbUpdate(Persistable|Collection $persistable): bool
{
if ($persistable instanceof Collection) {
- $isUpdate = $persistable->reduce([$this, 'isPersistableUpdate'], false);
+ $isUpdate = $persistable->reduce(
+ Closure::fromCallable([$this, 'isPersistableUpdate']),
+ true
+ );
} else {
- $isUpdate = $this->isPersistableUpdate($persistable);
+ $isUpdate = $this->isPersistableUpdate(true, $persistable);
}
return $isUpdate;
}
@@ -178,13 +187,13 @@ protected function performInsertOperation(Persistable|Collection $object): void
$isCollection = ($object instanceof Collection);
$isEntity = ($object instanceof Persistable);
if ($isCollection) {
- $meta = $this->getMeta($object->getEntityClass());
+ $meta = $this->meta->get($object->getEntityClass());
$params = [];
foreach ($object as $item) {
$params[] = $this->getDbParams($item, $meta, excludePrimaryKey: true);
}
} else {
- $meta = $this->getMeta($object::class);
+ $meta = $this->meta->get($object::class);
$params = $this->getDbParams($object, $meta, excludePrimaryKey: true);
}
if (($isCollection && $object->count() > 0 && count($params) > 0) || $isEntity) {
@@ -205,7 +214,6 @@ protected function performInsertOperation(Persistable|Collection $object): void
foreach ($object as $item) {
if ($this->isDbInsert($item)) {
$this->setPrimaryKeyValue($item, $lastId);
- $item->resetModified();
$lastId++;
}
$item->resetModified();
@@ -246,13 +254,13 @@ protected function performSingleUpdate(Persistable $object): void
{
$params = $this->getDbParams(
$object,
- $this->getMeta($object::class),
+ $this->meta->get($object::class),
modified: true,
excludePrimaryKey: true
);
if (!empty($params)) {
$itemSql = $this->db->getUpdateQuery(
- $this->getMeta($object::class),
+ $this->meta->get($object::class),
$object->getModified()
);
$itemParams = array_values($params);
@@ -262,6 +270,7 @@ protected function performSingleUpdate(Persistable $object): void
'The database records could not be updated'
);
}
+ $object->resetModified();
}
}
@@ -279,13 +288,13 @@ protected function performDeleteOperation(Persistable|Collection $object): void
// entities owned by this entity will also be deleted recursively
$this->recursivelyHandlePersistables($object, PersistOrder::AFTER, false);
if ($object instanceof Persistable) {
- $meta = $this->getMeta($object::class);
+ $meta = $this->meta->get($object::class);
$rows = 1;
if (!is_null($id = $this->getPrimaryKeyValue($object))) {
$params[] = $id;
}
} else {
- $meta = $this->getMeta($object->getEntityClass());
+ $meta = $this->meta->get($object->getEntityClass());
$rows = $object->count();
foreach ($object as $item) {
if (!is_null($id = $this->getPrimaryKeyValue($item))) {
@@ -347,7 +356,7 @@ protected function getDbParams(
foreach ($object->getModified()->getNames() as $property) {
if ($cols->has($property)) {
$column = $cols->get($property);
- if ($excludePrimaryKey && $column->primary) {
+ if ($excludePrimaryKey && $this->isPrimaryKey($property, $meta)) {
continue;
}
$method = $this->accessorName($object, $property);
@@ -356,7 +365,7 @@ protected function getDbParams(
}
} else {
foreach ($cols as $property => $column) {
- if (($excludePrimaryKey && $column->primary) || $column->readonly) {
+ if (($excludePrimaryKey && $this->isPrimaryKey($property, $meta)) || $column->readonly) {
continue;
}
$method = $this->accessorName($object, $property);
@@ -377,7 +386,43 @@ protected function getDbParams(
*/
protected function getPrimaryKey(Persistable $object): string
{
- return $this->getMeta($object::class)->getTable()->primaryKey;
+ return $this->resolvePrimaryKeyName($this->meta->get($object::class));
+ }
+
+ protected function getPrimaryKeyProperty(Meta $meta): string
+ {
+ $pk = $meta->getTable()->primaryKey;
+ $property = '';
+ foreach ($meta->getColumns() as $key => $column) {
+ if ($pk === null && $column->primary) {
+ $property = $key;
+ break;
+ } else if ($pk !== null && ($column->name ?? $key) === $pk) {
+ $property = $key;
+ break;
+ }
+ }
+
+ return $property;
+ }
+
+ protected function isPrimaryKey(string $property, Meta $meta): bool
+ {
+ return $property === $this->resolvePrimaryKeyName($meta);
+ }
+
+ protected function resolvePrimaryKeyName(Meta $meta): string
+ {
+ if (($key = $meta->getTable()->primaryKey) === null) {
+ foreach ($meta->getColumns() as $property => $column) {
+ if ($column->primary) {
+ // @todo: allow for multiple compound keys
+ $key = $column->name ?? $property;
+ break;
+ }
+ }
+ }
+ return $key ?? '';
}
/**
@@ -392,7 +437,10 @@ protected function getPrimaryKey(Persistable $object): string
*/
protected function getPrimaryKeyValue(Persistable $object): mixed
{
- $method = $this->accessorName($object, $this->getPrimaryKey($object));
+ $method = $this->accessorName(
+ $object,
+ $this->getPrimaryKeyProperty($this->meta->get($object::class))
+ );
return $object->$method();
}
@@ -421,30 +469,16 @@ protected function setPrimaryKeyValue(Persistable $object, mixed $value): void
$object->$method($value);
}
- /**
- * Uses reflection to parse metadata from a FQDN for a class which extends
- * Persistable. Table and Column attribute data is cached for each class.
- *
- * @param string $class
- *
- * @return Meta
- * @throws ReflectionException
- */
- private function getMeta(string $class): Meta
- {
- return $this->meta->get($class);
- }
-
/**
* Determine if the object instance should be inserted into the database.
*
+ * @param bool $carry Used with a collection and the reduce method
* @param Persistable $object The persistable instance object to evaluate
- * @param bool $carry Used with a collection and the reduce method
*
* @return bool
* @throws ReflectionException
*/
- private function isPersistableInsert(Persistable $object, bool $carry = true): bool
+ private function isPersistableInsert(bool $carry, Persistable $object): bool
{
if ($carry) {
$carry = is_null($this->getPrimaryKeyValue($object));
@@ -462,7 +496,7 @@ private function isPersistableInsert(Persistable $object, bool $carry = true): b
* @return bool
* @throws ReflectionException
*/
- private function isPersistableUpdate(Persistable $object, bool $carry = true): bool
+ private function isPersistableUpdate(bool $carry, Persistable $object): bool
{
if ($carry) {
if (!is_null($this->getPrimaryKeyValue($object))) {
@@ -480,7 +514,6 @@ private function isPersistableUpdate(Persistable $object, bool $carry = true): b
*/
private function getPersistables(Persistable $object, Meta $meta, PersistOrder $order): ?Collection
{
-
$entities = ($meta->getPersistables() ?? new Entities\Collection());
$childMeta = $entities->filter(function (Entity $entity) use ($order) {
return $entity->order === $order;
@@ -499,23 +532,7 @@ private function getPersistables(Persistable $object, Meta $meta, PersistOrder $
}
$collection = null;
if (count($descendants)) {
- $collection = new class ([]) extends Collection {
- public function getEntityClass(): string
- {
- return Collection::class;
- }
-
- protected function validate(mixed $value): void
- {
- if (!($value instanceof Collection || $value instanceof Persistable)) {
- throw new InvalidArgumentException(sprintf(
- 'Value must be an instance of %s or %s',
- Collection::class,
- Persistable::class
- ));
- }
- }
- };
+ $collection = new Container();
foreach ($descendants as $property => $descendant) {
$collection->set($property, $descendant);
}
@@ -533,7 +550,12 @@ protected function validate(mixed $value): void
private function recursivelyHydrate(Persistable $persistable, Meta $meta): void
{
foreach ($meta->getPersistables() ?? [] as $entity) {
- $childMeta = $this->getMeta($entity->class);
+ if (is_subclass_of($entity->class, Collection::class)) {
+ $instance = new ($entity->class)();
+ $childMeta = $this->meta->get($instance->getEntityClass());
+ } else {
+ $childMeta = $this->meta->get($entity->class);
+ }
if ($entity->order === PersistOrder::BEFORE) {
$primaryKey = $entity->foreign ?? $childMeta->getTable()->primaryKey;
$getter = $this->accessorName($persistable, $primaryKey);
@@ -549,7 +571,11 @@ private function recursivelyHydrate(Persistable $persistable, Meta $meta): void
$setter = $entity->setter;
if ($entity->collection) {
$child = new ($entity->class)();
- $this->getEntityCollection($query, $child, []);
+ $this->getEntityCollection(
+ $query,
+ $child,
+ [$this->getPrimaryKeyValue($persistable)]
+ );
} else {
$child = $this->db->getQueryRow(
$query,
@@ -585,8 +611,8 @@ private function recursivelyHandlePersistables(
): void {
$isCollection = $persistable instanceof Collection;
$meta = $isCollection
- ? $this->getMeta($persistable->getEntityClass())
- : $this->getMeta($persistable::class);
+ ? $this->meta->get($persistable->getEntityClass())
+ : $this->meta->get($persistable::class);
if ($isCollection) {
foreach ($persistable as $item) {
$this->recurse(
diff --git a/src/Hydrator.php b/src/Hydrator.php
index 8a915a0..4322494 100644
--- a/src/Hydrator.php
+++ b/src/Hydrator.php
@@ -63,26 +63,6 @@ protected function getDateValue(
return $date;
}
- /**
- * Gets the formatted date from a DateTime object.
- *
- * If the default datetime 0000-00-00 00:00:00 was used to create $date,
- * returns null. Otherwise, returns the $date string formatted using DateTime::ATOM.
- *
- * @param DateTimeInterface|null $date
- *
- * @return string|null
- */
- public function getFormattedDate(?DateTimeInterface $date): ?string
- {
- if ($date instanceof DateTimeInterface && $date->getTimestamp() > -62169962964) {
- $date = $date->format(DateTime::ATOM);
- } else {
- $date = null;
- }
- return $date;
- }
-
/**
* Return a boolean value.
*
diff --git a/src/Modifications/Collection.php b/src/Modifications/Collection.php
index c061396..05817de 100644
--- a/src/Modifications/Collection.php
+++ b/src/Modifications/Collection.php
@@ -28,8 +28,7 @@ public function getNames(): ?Collections\Text
if ($this->count() > 0) {
$names = new Collections\Text();
foreach ($this as $modification) {
- $value = $modification->getName();
- $names->set($value, $value);
+ $names->append($modification->getName());
}
}
return $names;
diff --git a/src/Persistable.php b/src/Persistable.php
index c09b90b..1de8c9c 100644
--- a/src/Persistable.php
+++ b/src/Persistable.php
@@ -16,7 +16,9 @@ abstract class Persistable implements JsonSerializable
*/
public function getModified(): Modifications\Collection
{
- $this->initModifications();
+ if ($this->modified === null) {
+ $this->modified = new Modifications\Collection();
+ }
return $this->modified;
}
@@ -27,8 +29,7 @@ public function getModified(): Modifications\Collection
*/
public function resetModified(): void
{
- $this->initModifications();
- $this->modified->empty();
+ $this->getModified()->empty();
}
/**
@@ -42,7 +43,7 @@ public function rollbackModifications(): void
$name = $modification->getName();
$this->$name = $modification->getOldValue();
}
- $this->modified->empty();
+ $this->resetModified();
}
/**
@@ -62,23 +63,10 @@ abstract public function jsonSerialize(): mixed;
*/
protected function modify(string $name, mixed $new): void
{
- $this->initModifications();
$old = $this->$name;
if ($old !== $new) {
- $this->modified->append(Modification::from($name, $old, $new));
+ $this->getModified()->append(Modification::from($name, $old, $new));
$this->$name = $new;
}
}
-
- /**
- * Ensure the modifications collection exists.
- *
- * @return void
- */
- protected function initModifications(): void
- {
- if ($this->modified === null) {
- $this->modified = new Modifications\Collection();
- }
- }
}
diff --git a/tests/unit/CollectionTest.php b/tests/unit/CollectionTest.php
new file mode 100644
index 0000000..359775f
--- /dev/null
+++ b/tests/unit/CollectionTest.php
@@ -0,0 +1,31 @@
+append($expected);
+
+ $this->assertSame($expected, $unit->getFirst());
+ $this->expectException(InvalidArgumentException::class);
+
+ $unit->append($this->createMock(ComplexAggregate::class));
+ }
+}
diff --git a/tests/unit/ContainerTest.php b/tests/unit/ContainerTest.php
new file mode 100644
index 0000000..28aeb5d
--- /dev/null
+++ b/tests/unit/ContainerTest.php
@@ -0,0 +1,51 @@
+assertCount(0, $unit);
+ $unit->append($collection);
+ $unit->append($persistable);
+ $this->assertCount(2, $unit);
+
+ $this->expectException(InvalidArgumentException::class);
+ $unit->append($foobar);
+ }
+
+ public function testContainerEntityClass(): void
+ {
+ $unit = new Container();
+ $this->assertEquals(Collection::class, $unit->getEntityClass());
+ }
+}
diff --git a/tests/unit/Databases/Attributes/ColumnTest.php b/tests/unit/Databases/Attributes/ColumnTest.php
new file mode 100644
index 0000000..65a7358
--- /dev/null
+++ b/tests/unit/Databases/Attributes/ColumnTest.php
@@ -0,0 +1,21 @@
+assertSame('columnName', $unit->name);
+ $this->assertSame('table', $unit->table);
+ $this->assertSame(true, $unit->primary);
+ $this->assertSame(true, $unit->readonly);
+ $this->assertSame('getter', $unit->getter);
+ $this->assertSame('setter', $unit->setter);
+ }
+}
diff --git a/tests/unit/Databases/Attributes/Columns/CollectionTest.php b/tests/unit/Databases/Attributes/Columns/CollectionTest.php
new file mode 100644
index 0000000..8078f8e
--- /dev/null
+++ b/tests/unit/Databases/Attributes/Columns/CollectionTest.php
@@ -0,0 +1,17 @@
+expectException(InvalidArgumentException::class);
+
+ $unit = new Collection([null]);
+ }
+}
diff --git a/tests/unit/Databases/Attributes/Entities/CollectionTest.php b/tests/unit/Databases/Attributes/Entities/CollectionTest.php
new file mode 100644
index 0000000..e2d7bcc
--- /dev/null
+++ b/tests/unit/Databases/Attributes/Entities/CollectionTest.php
@@ -0,0 +1,17 @@
+expectException(InvalidArgumentException::class);
+
+ $unit = new Collection([null]);
+ }
+}
diff --git a/tests/unit/Databases/Attributes/EntityTest.php b/tests/unit/Databases/Attributes/EntityTest.php
new file mode 100644
index 0000000..0aaf9a8
--- /dev/null
+++ b/tests/unit/Databases/Attributes/EntityTest.php
@@ -0,0 +1,30 @@
+assertSame('className', $unit->class);
+ $this->assertSame('foreignKey', $unit->foreign);
+ $this->assertSame(true, $unit->nullable);
+ $this->assertSame(true, $unit->collection);
+ $this->assertSame('getter', $unit->getter);
+ $this->assertSame('setter', $unit->setter);
+ $this->assertSame(PersistOrder::AFTER, $unit->order);
+ }
+}
diff --git a/tests/unit/Databases/Attributes/JoinTest.php b/tests/unit/Databases/Attributes/JoinTest.php
new file mode 100644
index 0000000..2d6f200
--- /dev/null
+++ b/tests/unit/Databases/Attributes/JoinTest.php
@@ -0,0 +1,27 @@
+assertSame('JOIN', $unit->type);
+ $this->assertSame('table', $unit->table);
+ $this->assertSame('key', $unit->key);
+ $this->assertSame('foreign', $unit->foreign);
+ $this->assertSame('target', $unit->target);
+ }
+
+ public function testWillThrowExceptionForBadJoin(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new Join('type', 'table', 'key');
+ }
+}
diff --git a/tests/unit/Databases/Attributes/Joins/CollectionTest.php b/tests/unit/Databases/Attributes/Joins/CollectionTest.php
new file mode 100644
index 0000000..337fc0c
--- /dev/null
+++ b/tests/unit/Databases/Attributes/Joins/CollectionTest.php
@@ -0,0 +1,17 @@
+expectException(InvalidArgumentException::class);
+
+ $unit = new Collection([null]);
+ }
+}
diff --git a/tests/unit/Databases/Attributes/TableTest.php b/tests/unit/Databases/Attributes/TableTest.php
new file mode 100644
index 0000000..027697e
--- /dev/null
+++ b/tests/unit/Databases/Attributes/TableTest.php
@@ -0,0 +1,17 @@
+assertSame('table', $unit->name);
+ $this->assertSame('primaryKey', $unit->primaryKey);
+ }
+}
diff --git a/tests/unit/Databases/ConnectionTest.php b/tests/unit/Databases/ConnectionTest.php
new file mode 100644
index 0000000..284c029
--- /dev/null
+++ b/tests/unit/Databases/ConnectionTest.php
@@ -0,0 +1,53 @@
+createMock(PDO::class);
+ $fn = function(
+ string $db,
+ string $host,
+ string $user,
+ string $pass,
+ string $charset) use ($pdo) {
+ return $pdo;
+ };
+
+ $unit = new Connection(
+ 'database',
+ 'host',
+ 'user',
+ 'password',
+ 'driver',
+ 'charset',
+ $fn
+ );
+
+ $this->assertInstanceOf(PDO::class, $unit->getPdo());
+ $this->assertInstanceOf(SqlGenerator::class, $unit->getSqlGenerator());
+ }
+
+ public function testWillReturnNull(): void
+ {
+ $unit = new Connection(
+ 'database',
+ 'hostname',
+ 'username',
+ 'password',
+ 'driver',
+ 'utf8',
+ function () {
+ return null;
+ });
+
+ $this->assertNull($unit->getPdo());
+ }
+}
diff --git a/tests/unit/Databases/Meta/CollectionTest.php b/tests/unit/Databases/Meta/CollectionTest.php
new file mode 100644
index 0000000..f25cc62
--- /dev/null
+++ b/tests/unit/Databases/Meta/CollectionTest.php
@@ -0,0 +1,25 @@
+createMock(Meta::class);
+ $unit = new Collection();
+ $unit->set('a', $mock);
+ $this->assertSame($mock, $unit->get('a'));
+ }
+
+ public function testValidateCollection(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new Collection([null]);
+ }
+}
diff --git a/tests/unit/Databases/Meta/FactoryTest.php b/tests/unit/Databases/Meta/FactoryTest.php
new file mode 100644
index 0000000..1340c63
--- /dev/null
+++ b/tests/unit/Databases/Meta/FactoryTest.php
@@ -0,0 +1,130 @@
+get(SimpleEntity::class);
+ $m1b = $f1->get(SimpleEntity::class);
+ $this->assertSame($m1a, $m1b, 'Cached meta should be reused within the same instance');
+
+ $f2 = MetaFactory::getInstance(true);
+ $m2 = $f2->get(SimpleEntity::class);
+ $this->assertNotSame($m1a, $m2, 'Fresh instance should rebuild meta cache');
+ }
+
+ public function testMissingTableThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ MetaFactory::getInstance(true)->get(MissingTable::class);
+ }
+
+ public function testNoColumnsThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ MetaFactory::getInstance(true)->get(NoColumnsEntity::class);
+ }
+
+ public function testBadJoinTypeThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ MetaFactory::getInstance(true)->get(BadJoinType::class);
+ }
+
+ public function testSimpleEntityColumnsAndAccessorsAreInferred(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(SimpleEntity::class);
+
+ $this->assertSame('simple_entities', $meta->getTable()->name);
+ $cols = $meta->getColumns();
+ $this->assertTrue($cols->has('id'));
+ $this->assertTrue($cols->has('name'));
+
+ $id = $cols->get('id');
+ $this->assertTrue($id->primary);
+ $this->assertSame('getId', $id->getter);
+ $this->assertSame('setId', $id->setter);
+
+ $name = $cols->get('name');
+ $this->assertSame('name', $name->name);
+ $this->assertSame('getName', $name->getter);
+ $this->assertSame('setName', $name->setter);
+ }
+
+ public function testReadonlyColumnHasNoSetter(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(ReadonlyColumnEntity::class);
+ $created = $meta->getColumns()->get('createdAt');
+ $this->assertTrue($created->readonly);
+ $this->assertNotNull($created->getter);
+ $this->assertNull($created->setter, 'Readonly column should not have a setter');
+ }
+
+ public function testExplicitCustomAccessorIsUsed(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(WithCustomAccessor::class);
+ $id = $meta->getColumns()->get('id');
+ $this->assertSame('fetchId', $id->getter);
+ $this->assertSame('storeId', $id->setter);
+ }
+
+ public function testBadAccessorThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ MetaFactory::getInstance(true)->get(BadAccessorEntity::class);
+ }
+
+ public function testEntityExplicitClassAndForeignAndAccessors(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(WithEntityExplicit::class);
+ $ents = $meta->getPersistables();
+ $this->assertNotNull($ents);
+ $this->assertTrue($ents->has('child'));
+
+ $e = $ents->get('child');
+ $this->assertSame(SimpleEntity::class, $e->class);
+ $this->assertSame('entityId', $e->foreign);
+ $this->assertFalse($e->nullable);
+ $this->assertSame('getChild', $e->getter);
+ $this->assertSame('setChild', $e->setter);
+ }
+
+ public function testEntityImplicitFromPropertyTypeSetsNullable(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(WithEntityImplicitNullable::class);
+ $e = $meta->getPersistables()->get('child');
+ $this->assertSame(SimpleEntity::class, $e->class);
+ $this->assertTrue($e->nullable, 'Nullable typed entity should set nullable=true');
+ }
+
+ public function testEntityUnionTypeInferenceChoosesPersistable(): void
+ {
+ $meta = MetaFactory::getInstance(true)->get(WithEntityUnion::class);
+ $e = $meta->getPersistables()->get('child');
+ $this->assertSame(SimpleEntity::class, $e->class, 'Union type should select the Persistable subtype');
+ }
+
+ public function testEntityUnionWithNoPersistableThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ MetaFactory::getInstance(true)->get(BadUnionEntity::class);
+ }
+}
diff --git a/tests/unit/Databases/MetaTest.php b/tests/unit/Databases/MetaTest.php
new file mode 100644
index 0000000..82d7dc1
--- /dev/null
+++ b/tests/unit/Databases/MetaTest.php
@@ -0,0 +1,28 @@
+createMock(Table::class);
+ $columns = $this->createMock(Columns\Collection::class);
+ $joins = $this->createMock(Joins\Collection::class);
+ $entities = $this->createMock(Entities\Collection::class);
+
+ $unit = new Meta($table, $columns, $joins, $entities);
+
+ $this->assertSame($table, $unit->getTable());
+ $this->assertSame($columns, $unit->getColumns());
+ $this->assertSame($joins, $unit->getJoins());
+ $this->assertSame($entities, $unit->getPersistables());
+ }
+}
diff --git a/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php b/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php
new file mode 100644
index 0000000..6254135
--- /dev/null
+++ b/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php
@@ -0,0 +1,217 @@
+assertSame($unit, $actual);
+ }
+
+ public function testGetSelectQuery(): void
+ {
+ $expected = << new Column('alpha_id'),
+ 'name' => new Column('alpha_name'),
+ ];
+ $unit = MySqlGenerator::getInstance();
+ $meta = new Meta(
+ new Table('alpha', 'alpha_id'),
+ new Columns\Collection($columns),
+ null,
+ null,
+ );
+
+ $actual = $unit->getSelectQuery($meta);
+
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testGetSelectQueryWithJoins(): void
+ {
+ $expected = << new Column(),
+ 'alpha' => new Column('alpha_name'),
+ 'beta' => new Column('beta_name'),
+ 'gamma' => new Column('gamma_name'),
+ 'delta' => new Column('d', 'roman', true),
+ ];
+ $joins = [
+ new Join('JOIN', 'roman', 'id', 'greek_id'),
+ ];
+ $meta = new Meta(
+ new Table('greek', 'id'),
+ new Columns\Collection($columns),
+ new Joins\Collection($joins),
+ null
+ );
+
+ $unit = MySqlGenerator::getInstance();
+ $actual = $unit->getSelectQuery($meta);
+
+ $this->assertEquals($expected, $actual);
+
+ }
+
+ public function testGetSelectQueryWillFormatColumnNames(): void
+ {
+ $expected = << new Column(),
+ 'beta' => new Column(),
+ 'gamma' => new Column(),
+ 'delta' => new Column(),
+ ];
+ $meta = new Meta(
+ new Table('greek', 'alpha'),
+ new Columns\Collection($columns),
+ null,
+ null
+ );
+
+ $unit = MySqlGenerator::getInstance();
+
+ $actual = $unit->getSelectQuery($meta, "`delta` = 12");
+
+ $this->assertSame($expected, $actual);
+
+ }
+
+ public function testGetInsertQuery(): void
+ {
+ $expected = << new Column('alpha_id'),
+ 'name' => new Column('alpha_name'),
+ ];
+ $unit = MySqlGenerator::getInstance();
+ $meta = new Meta(
+ new Table('alpha', 'alpha_id'),
+ new Columns\Collection($columns),
+ null,
+ null,
+ );
+ $actual = $unit->getInsertQuery($meta);
+
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testGetUpdateQuery(): void
+ {
+ $expected = << new Column('alpha'),
+ 'b' => new Column('beta'),
+ 'c' => new Column('gamma'),
+ 'd' => new Column('delta'),
+ 'e' => new Column('epsilon'),
+ ];
+ $modifications = $this->createMock(Modifications\Collection::class);
+ $modifications->expects($this->once())
+ ->method('count')
+ ->willReturn(3);
+ $modifications->expects($this->any())
+ ->method('getNames')
+ ->willReturn(new Text(['a', 'c', 'e']));
+ $unit = MySqlGenerator::getInstance();
+ $meta = new Meta(
+ new Table('greek', 'id'),
+ new Columns\Collection($columns),
+ null,
+ null
+ );
+
+ $actual = $unit->getUpdateQuery($meta, $modifications);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetDeleteQuery(): void
+ {
+ $expected = <<getDeleteQuery($meta);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetDeleteQueryWithMultipleRows(): void
+ {
+ $expected = <<getDeleteQuery($meta, 5);
+
+ $this->assertEquals($expected, $actual);
+
+ }
+}
diff --git a/tests/unit/Databases/SqlTest.php b/tests/unit/Databases/SqlTest.php
new file mode 100644
index 0000000..5a776de
--- /dev/null
+++ b/tests/unit/Databases/SqlTest.php
@@ -0,0 +1,746 @@
+connection = $this->createMock(Connection::class);
+ $this->pdo = $this->createMock(PDO::class);
+ $this->stmt = $this->createMock(PDOStatement::class);
+ }
+
+ public function testDatabaseInstanceIsSingleton(): void
+ {
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $actual = Sql::getInstance($this->connection, true);
+ $expected = Sql::getInstance(null);
+
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testWillThrowExceptionForBadConnection(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ Sql::getInstance(null, true);
+ }
+
+ public function testWillPassThroughSqlGenerator(): void
+ {
+ $expected = [
+ 'select' => 'SELECT',
+ 'insert' => 'INSERT INTO',
+ 'update' => 'UPDATE',
+ 'delete' => 'DELETE',
+ ];
+ $generator = $this->createMock(SqlGenerator::class);
+ $generator->expects($this->once())
+ ->method('getSelectQuery')
+ ->willReturn($expected['select']);
+ $generator->expects($this->once())
+ ->method('getInsertQuery')
+ ->willReturn($expected['insert']);
+ $generator->expects($this->once())
+ ->method('getUpdateQuery')
+ ->willReturn($expected['update']);
+ $generator->expects($this->once())
+ ->method('getDeleteQuery')
+ ->willReturn($expected['delete']);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $this->connection->expects($this->once())
+ ->method('getSqlGenerator')
+ ->willReturn($generator);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $meta = new Meta(new Table('table'), new Columns\Collection(), null, null);
+ $mods = new Modifications\Collection();
+ $this->assertSame($expected['select'], $unit->getSelectQuery($meta));
+ $this->assertSame($expected['insert'], $unit->getInsertQuery($meta));
+ $this->assertSame($expected['update'], $unit->getUpdateQuery($meta, $mods));
+ $this->assertSame($expected['delete'], $unit->getDeleteQuery($meta));
+
+ }
+
+ public function testCanGetArrayOfObjectsWithGetQueryData(): void
+ {
+ $sql = "SELECT * FROM `orders`";
+ $this->stmt->expects($this->once())
+ ->method('fetchAll')
+ ->with(PDO::FETCH_OBJ)
+ ->willReturn([(object)['id' => 5, 'status' => 'fulfilled']]);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getQueryData($sql);
+
+ $this->assertIsArray($actual);
+ $this->assertIsObject($actual[0]);
+ }
+
+ public function testCanGetArrayOfEntitiesWithGetQueryData(): void
+ {
+ $mock1 = $this->createMock(Persistable::class);
+ $mock2 = $this->createMock(Persistable::class);
+ $sql = "SELECT * FROM `orders`";
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->stmt->expects($this->once())
+ ->method('fetchAll')
+ ->with(PDO::FETCH_CLASS, Persistable::class)
+ ->willReturn([$mock1, $mock2]);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getQueryData($sql, [], PDO::FETCH_CLASS, Persistable::class);
+
+ $this->assertIsArray($actual);
+ $this->assertInstanceOf(Persistable::class, $actual[0]);
+ }
+
+ public function testCanGetStringWithGetQueryResult(): void
+ {
+ $sql = "SELECT COUNT(*) FROM `customers`";
+ $this->stmt->expects($this->once())
+ ->method('fetchColumn')
+ ->willReturn('string');
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getQueryResult($sql);
+
+ $this->assertIsString($actual);
+ }
+
+ public function testCanGetIntegerWithGetQueryResult(): void
+ {
+ $expected = 500;
+ $sql = "SELECT COUNT(*) FROM `customers`";
+ $this->stmt->expects($this->once())
+ ->method('fetchColumn')
+ ->willReturn(strval($expected));
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->getQueryResult($sql);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testCanGetFloatWithGetQueryResult(): void
+ {
+ $expected = 3.14;
+ $sql = "SELECT AVG(`total`) FROM `orders`";
+ $this->stmt->expects($this->once())
+ ->method('fetchColumn')
+ ->willReturn(strval($expected));
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->getQueryResult($sql);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetQueryResultWillReturnEmptyStringOnPdoException(): void
+ {
+ $sql = "SELECT `a`, `b`, `c` FROM `table` WHERE `id` = 1";
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willThrowException(new PDOException());
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->getQueryResult($sql);
+
+ $this->assertEquals('', $actual);
+ }
+
+ public function testCanGetArrayOfStringsWithGetQueryRow(): void
+ {
+ $sql = "SELECT `a`, `b`, `c` FROM `table` WHERE `id` = 1";
+ $expected = ['1', '2', '3'];
+
+ $this->stmt->expects($this->once())
+ ->method('fetch')
+ ->willReturn($expected);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getQueryRow($sql);
+
+ $this->assertIsArray($actual);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testCanGetEntityWithGetQueryRow(): void
+ {
+ $sql = "SELECT `a`, `b`, `c` FROM `table` WHERE `id` = 1";
+ $mock = $this->createMock(Persistable::class);
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->stmt->expects($this->once())
+ ->method('fetchAll')
+ ->with(PDO::FETCH_CLASS, Persistable::class)
+ ->willReturn([$mock]);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->getQueryRow($sql, [], PDO::FETCH_CLASS, Persistable::class);
+
+ $this->assertInstanceOf(Persistable::class, $actual);
+ }
+
+ public function testCanGetArrayOfStringsWithGetQueryColumn(): void
+ {
+ $sql = "SELECT `x` FROM `table` WHERE `id` IN(1, 3, 5, 7, 9)";
+ $expected = ['2', '4', '6', '8', '0'];
+ $values = ['2', '4', '6', '8', '0', false];
+
+ $this->stmt->expects($this->exactly(6))
+ ->method('fetchColumn')
+ ->willReturnCallback(function () use (&$values) {
+ return array_shift($values);
+ });
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getQueryColumn($sql);
+
+ $this->assertIsArray($actual);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testCanGetIdForInsertQuery(): void
+ {
+ $sql = "INSERT INTO `table` VALUES (NULL, 'a', 'b', 'c')";
+ $expected = '1000';
+
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->pdo->expects($this->once())
+ ->method('lastInsertId')
+ ->willReturn($expected);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getIdForInsert($sql);
+
+ $this->assertIsInt($actual);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetIdForInsertWillReturnZeroIfSomethingGoesWrong(): void
+ {
+ $sql = "INSERT INTO `table` VALUES (NULL, 'alpha'), (NULL, 'beta')";
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willReturn(false);
+ $this->stmt
+ ->expects($this->once())
+ ->method('errorInfo')
+ ->willReturn(['','','']);
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $this->assertEquals(0, $unit->getIdForInsert($sql));
+ }
+
+ public function testGetIdForInsertWillThrowExceptionForBadQuery(): void
+ {
+ $sql = "SELECT * FROM `table`";
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $this->expectException(InvalidArgumentException::class);
+ $unit->getIdForInsert($sql);
+ }
+
+ public function testCanGetCountForUpdateQuery(): void
+ {
+ $sql = "UPDATE `table` SET `a` = 2, `b` = 2, `c` = 3";
+ $expected = 500;
+
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->stmt->expects($this->once())
+ ->method('rowCount')
+ ->willReturn($expected);
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $db = Sql::getInstance($this->connection, true);
+ $actual = $db->getNumRowsAffectedForUpdate($sql);
+
+ $this->assertIsInt($actual);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetNumRowsAffectedWillThrowExceptionForBadQuery(): void
+ {
+ $sql = "SELECT * FROM `table`";
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $this->expectException(InvalidArgumentException::class);
+ $unit->getNumRowsAffectedForUpdate($sql);
+ }
+
+ public function testGetNumRowsAffectedWillReturnZeroIfSomethingGoesWrong(): void
+ {
+ $sql = "UPDATE `table` SET `column_one` = 1";
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willReturn(false);
+ $this->stmt
+ ->expects($this->once())
+ ->method('errorInfo')
+ ->willReturn(['','','']);
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $this->assertEquals(0, $unit->getNumRowsAffectedForUpdate($sql));
+ }
+
+ public function testGetNumRowsAffectedWillReturnZeroOnPDOException(): void
+ {
+ $sql = "UPDATE `table` SET `column_one` = 1";
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn(false);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $this->assertEquals(0, $unit->getNumRowsAffectedForUpdate($sql));
+ }
+
+ public function testExecuteWillInvokeTheDatabase(): void
+ {
+ $sql = "SELECT * FROM `table`";
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->with($this->equalTo([]))
+ ->willReturn(true);
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection
+ ->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->execute($sql);
+ $this->assertTrue($actual);
+ }
+
+ public function testExecuteWillUseComplexParameters(): void
+ {
+ $sql = "SELECT * FROM `table` WHERE `name` = ? LIMIT ? OFFSET ?";
+ $params = [
+ 5,
+ (object) [
+ 'value' => 50,
+ 'type' => PDO::PARAM_INT,
+ ],
+ (object) [
+ 'value' => 100,
+ 'type' => PDO::PARAM_INT,
+ ]
+ ];
+
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection
+ ->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->execute($sql, $params);
+ $this->assertTrue($actual);
+
+ }
+
+ public function testExecuteTransactionWillInvokeTheDatabase(): void
+ {
+ $query1 = $this->createMock(SqlCommand::class);
+ $query1->expects($this->once())
+ ->method('getQuery')
+ ->willReturn('UPDATE `table` SET `name` = ? WHERE `id` = ?');
+ $query1->expects($this->once())
+ ->method('getData')
+ ->willReturn([5]);
+ $query2 = $this->createMock(SqlCommand::class);
+ $query2->expects($this->once())
+ ->method('getQuery')
+ ->willReturn('DELETE FROM `table` WHERE `id` = ?`');
+ $query2->expects($this->once())
+ ->method('getData')
+ ->willReturn([91]);
+ $collection = $this->createMock(Queries\Collection::class);
+ $collection->method('getIterator')
+ ->willReturn(new ArrayIterator([$query1, $query2]));
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $this->pdo
+ ->expects($this->once())
+ ->method('beginTransaction');
+ $this->pdo
+ ->expects($this->exactly(2))
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->pdo
+ ->expects($this->once())
+ ->method('commit');
+ $this->stmt
+ ->expects($this->exactly(2))
+ ->method('execute')
+ ->willReturn(true);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $unit->executeTransaction($collection);
+ }
+
+ public function testExecuteTransactionWillRollbackOnError(): void
+ {
+ $sql = "UPDATE `table` SET `name` = ? WHERE `id` = ?";
+ $query1 = $this->createMock(SqlCommand::class);
+ $query1->expects($this->once())
+ ->method('getQuery')
+ ->willReturn($sql);
+ $query1->expects($this->once())
+ ->method('getData')
+ ->willReturn([20]);
+ $query2 = $this->createMock(SqlCommand::class);
+ $collection = $this->createMock(Queries\Collection::class);
+ $collection->method('getIterator')
+ ->willReturn(new ArrayIterator([$query1, $query2]));
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $this->pdo
+ ->expects($this->once())
+ ->method('beginTransaction');
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->pdo
+ ->expects($this->once())
+ ->method('rollback');
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willReturn(false);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $unit->executeTransaction($collection);
+ }
+
+ public function testExecuteTransactionWillRollbackOnException(): void
+ {
+ $i = 0;
+ $sql = "UPDATE `table` SET `name` = ? WHERE `id` = ?";
+ $query1 = $this->createMock(SqlCommand::class);
+ $query1->expects($this->once())
+ ->method('getQuery')
+ ->willReturn($sql);
+ $query1->expects($this->once())
+ ->method('getData')
+ ->willReturn([20]);
+ $query2 = $this->createMock(SqlCommand::class);
+ $query2->expects($this->once())
+ ->method('getQuery')
+ ->willReturn($sql);
+ $query2->expects($this->once())
+ ->method('getData')
+ ->willReturn([21]);
+ $collection = $this->createMock(Queries\Collection::class);
+ $collection->method('getIterator')
+ ->willReturn(new ArrayIterator([$query1, $query2]));
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $this->pdo
+ ->expects($this->once())
+ ->method('beginTransaction');
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->pdo
+ ->expects($this->once())
+ ->method('rollback');
+ $this->pdo
+ ->expects($this->once())
+ ->method('commit')
+ ->willThrowException(new PDOException());
+ $this->stmt
+ ->expects($this->exactly(2))
+ ->method('execute')
+ ->willReturn(true);
+ $unit = Sql::getInstance($this->connection, true);
+ $unit->executeTransaction($collection);
+ }
+
+ public function testExecuteWillHandleQueryFailures(): void
+ {
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo(""))
+ ->willReturn(false);
+ $this->connection
+ ->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->execute("");
+ $this->assertFalse($actual);
+ }
+
+ public function testExecuteWillHandlePDOExceptions(): void
+ {
+ $sql = "SELECT * FROM `missing_table`";
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willThrowException(new PDOException());
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection
+ ->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->execute($sql);
+ $this->assertFalse($actual);
+ }
+
+ public function testCanQuoteString(): void
+ {
+ $argument = 'whatever';
+ $expected = "'whatever'";
+
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $this->pdo->expects($this->once())
+ ->method('quote')
+ ->with($this->equalTo($argument))
+ ->willReturn($expected);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->quote($argument);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testQuoteWillReturnNullOnError(): void
+ {
+ $argument = "'foobar'";
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+
+ $this->pdo->expects($this->once())
+ ->method('quote')
+ ->with($this->equalTo($argument))
+ ->willReturn(false);
+
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->quote($argument);
+
+ $this->assertNull($actual);
+
+ }
+
+ public function testDoesReturnErrorInfoOnExceptions(): void
+ {
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->with("")
+ ->willReturn(false);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $actual = $unit->getQueryData("");
+
+ $this->assertEquals([], $actual);
+ $errors = $unit->getErrors();
+ $this->assertIsArray($errors);
+ }
+
+ public function testDoesCatchExceptionsAndReturnEmptyArray(): void
+ {
+ $expected = [
+ 'msg' => 'There was an error',
+ 'code' => 500,
+ ];
+ $this->stmt->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $this->stmt->expects($this->once())
+ ->method('fetchAll')
+ ->will($this->throwException(
+ new Exception($expected['msg'], $expected['code'])
+ ));
+ $this->pdo->expects($this->once())
+ ->method('prepare')
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $data = $unit->getQueryData("SELECT 1");
+ $actual = $unit->getErrors();
+
+ $this->assertEquals([], $data);
+ $this->assertIsArray($actual);
+ $this->assertEquals($expected['msg'], $actual[0]->msg);
+ $this->assertEquals($expected['code'], $actual[0]->code);
+ }
+
+ public function testDoesCatchAndRecordDatabaseErrors(): void
+ {
+ $sql = "SELECT * FROM `missing-table`";
+ $this->stmt
+ ->expects($this->once())
+ ->method('execute')
+ ->willReturn(false);
+ $this->stmt
+ ->expects($this->once())
+ ->method('errorInfo')
+ ->willReturn([
+ '12345',
+ 0,
+ 'error message'
+ ]);
+ $this->pdo
+ ->expects($this->once())
+ ->method('prepare')
+ ->with($this->equalTo($sql))
+ ->willReturn($this->stmt);
+ $this->connection->expects($this->once())
+ ->method('getPdo')
+ ->willReturn($this->pdo);
+ $unit = Sql::getInstance($this->connection, true);
+ $unit->execute($sql);
+ }
+}
diff --git a/tests/unit/FactoryTest.php b/tests/unit/FactoryTest.php
new file mode 100644
index 0000000..3c0521d
--- /dev/null
+++ b/tests/unit/FactoryTest.php
@@ -0,0 +1,1146 @@
+setId(1);
+ $expected->setName('Omega');
+ $expected->resetModified();
+
+ $sql = <<createMock(Meta::class);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getSelectQuery')
+ ->with($this->equalTo($meta))
+ ->willReturn($sql);
+ $database->expects($this->once())
+ ->method('getQueryRow')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo([1]),
+ $this->equalTo(PDO::FETCH_CLASS),
+ $this->equalTo(SimpleEntity::class)
+ )->willReturn($expected);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->once())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+ $actual = $unit->getEntityByPrimaryKey(SimpleEntity::class, 1);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetEntityByPrimaryKeyWithAggregate(): void
+ {
+ $sql = <<createMock(SimpleEntity::class);
+
+ $entity = $this->createMock(WithEntityExplicit::class);
+ $entity->expects($this->once())
+ ->method('getEntityId')
+ ->willReturn(25);
+ $entity->expects($this->once())
+ ->method('setChild')
+ ->with($this->equalTo($childEntity));
+
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('with_entity_explicit', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(),
+ ]));
+ $meta->expects($this->any())
+ ->method('getPersistables')
+ ->willReturn(new Entities\Collection([
+ new Entity(
+ SimpleEntity::class,
+ 'entityId',
+ getter: 'getChild',
+ setter: 'setChild',
+ order: PersistOrder::BEFORE
+ ),
+ ]));
+
+ $child = $this->getMetaDataMock();
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->exactly(2))
+ ->method('getSelectQuery')
+ ->willReturnCallback(function ($class, $clause) use ($sql, $childSql) {
+ if ($class === SimpleEntity::class) {
+ $this->assertEquals('`simple_entities`.`id` = ?', $clause);
+ return $childSql;
+ }
+ $this->assertEquals(null, $clause);
+ return $sql;
+ });
+ $database->expects($this->exactly(2))
+ ->method('getQueryRow')
+ ->willReturnCallback(function ($query, $params, $type, $class) use ($childEntity, $entity) {
+ if ($class === SimpleEntity::class) {
+ return $childEntity;
+ }
+ return $entity;
+ });
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturnCallback(function (string $name) use ($meta, $child) {
+ if ($name === SimpleEntity::class) {
+ return $child;
+ }
+ return $meta;
+ });
+
+ $unit = new Factory($database, $metaFactory);
+
+ $actual = $unit->getEntityByPrimaryKey(WithEntityExplicit::class, 1);
+
+ $this->assertInstanceOf(WithEntityExplicit::class, $actual);
+ $this->assertInstanceOf(SimpleEntity::class, $actual->getChild());
+ }
+
+ public function testGetEntityByPrimaryKeyWithComplexAggregate(): void
+ {
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('complex_aggregates', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(getter: 'getId', setter: 'setId'),
+ 'entityId' => new Column(getter: 'getEntityId', setter: 'setEntityId'),
+ ]));
+ $meta->expects($this->any())
+ ->method('getPersistables')
+ ->willReturn(new Entities\Collection([
+ 'entity' => new Entity(
+ SimpleEntity::class,
+ 'entityId',
+ true,
+ getter: 'getEntity',
+ setter: 'setEntity',
+ order: PersistOrder::BEFORE
+ ),
+ 'children' => new Entity(
+ Children::class,
+ 'aggregateId',
+ collection: true,
+ getter: 'getChildren',
+ setter: 'setChildren'
+ ),
+ ]));
+
+ $entityMeta = $this->getMetaDataMock();
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(5);
+ $entity->expects($this->any())
+ ->method('getName')
+ ->willReturn('Omega');
+
+ $childMeta = $this->createMock(Meta::class);
+ $childMeta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('child_entities'));
+ $childMeta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(primary: true),
+ 'name' => new Column(),
+ 'aggregateId' => new Column(),
+ ]));
+
+ $child1 = $this->createMock(ChildEntity::class);
+ $child2 = $this->createMock(ChildEntity::class);
+ $child3 = $this->createMock(ChildEntity::class);
+
+ $aggregate = $this->createMock(ComplexAggregate::class);
+ $aggregate->expects($this->any())
+ ->method('getId')
+ ->willReturn(1);
+ $aggregate->expects($this->any())
+ ->method('getEntityId')
+ ->willReturn(5);
+ $aggregate->expects($this->once())
+ ->method('setEntity')
+ ->with($this->equalTo($entity));
+ $aggregate->expects($this->once())
+ ->method('setChildren')
+ ->with($this->equalTo(new Children([$child1, $child2, $child3])));
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->exactly(3))
+ ->method('getSelectQuery')
+ ->willReturn('SELECT * FROM `table`');
+ $database->expects($this->exactly(2))
+ ->method('getQueryRow')
+ ->willReturnCallback(function (
+ string $sql,
+ array $data,
+ int $style,
+ ?string $class = null
+ ) use ($entity, $aggregate) {
+ if ($class === SimpleEntity::class) {
+ return $entity;
+ }
+ return $aggregate;
+ });
+ $database->expects($this->once())
+ ->method('getQueryData')
+ ->willReturn([$child1, $child2, $child3]);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturnCallback(function (string $name) use ($meta, $childMeta, $entityMeta) {
+ if (str_contains($name, 'ComplexAggregate')) {
+ $result = $meta;
+ } elseif (str_contains($name, 'SimpleEntity')) {
+ $result = $entityMeta;
+ } elseif (str_contains($name, 'ChildEntity')) {
+ $result = $childMeta;
+ } else {
+ $result = null;
+ }
+ return $result;
+ });
+
+ $unit = new Factory($database, $metaFactory);
+
+ $actual = $unit->getEntityByPrimaryKey(ComplexAggregate::class, 1);
+
+ $this->assertInstanceOf(ComplexAggregate::class, $actual);
+ }
+
+ public function testGetEntityByPrimaryKeyWithOwnerAggregate(): void
+ {
+ $aggregateSql = <<createMock(ChildEntity::class);
+
+ $aggregate = $this->createMock(OwnerAggregate::class);
+ $aggregate->expects($this->once())
+ ->method('getId')
+ ->willReturn(1);
+ $aggregate->expects($this->once())
+ ->method('setChild')
+ ->with($this->equalTo($child));
+
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('owner_aggregates'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column('aggregateId', primary: true, getter: 'getId', setter: 'setId'),
+ 'name' => new Column(getter: 'getName', setter: 'setName'),
+ ]));
+ $meta->expects($this->any())
+ ->method('getPersistables')
+ ->willReturn(new Entities\Collection([
+ 'child' => new Entity(ChildEntity::class, getter: 'getChild', setter: 'setChild'),
+ ]));
+
+ $childMeta = $this->createMock(Meta::class);
+ $childMeta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('child_entities', 'id'));
+ $childMeta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(),
+ 'name' => new Column(),
+ 'aggregateId' => new Column(),
+ ]));
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->exactly(2))
+ ->method('getQueryRow')
+ ->willReturnCallback(function (
+ $sql,
+ $params,
+ $style,
+ $class = null) use ($aggregate, $child) {
+ if (str_contains($class, 'OwnerAggregate')) {
+ $result = $aggregate;
+ } elseif (str_contains($class, 'ChildEntity')) {
+ $result = $child;
+ } else {
+ $result = false;
+ }
+ return $result;
+ });
+ $database->expects($this->exactly(2))
+ ->method('getSelectQuery')
+ ->willReturnCallback(function (Meta $meta, ?string $clause = null) use ($aggregateSql, $childSql) {
+ if ($meta->getTable()->name === 'owner_aggregates') {
+ $sql = $aggregateSql;
+ } elseif ($meta->getTable()->name === 'child_entities') {
+ $sql = $childSql;
+ } else {
+ $sql = null;
+ }
+ return $sql;
+ });
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturnCallback(function (string $name) use ($meta, $childMeta) {
+ if (str_contains($name, 'OwnerAggregate')) {
+ $result = $meta;
+ } elseif (str_contains($name, 'ChildEntity')) {
+ $result = $childMeta;
+ } else {
+ $result = null;
+ }
+ return $result;
+ });
+
+ $unit = new Factory($database, $metaFactory);
+ $actual = $unit->getEntityByPrimaryKey(OwnerAggregate::class, 1);
+
+ $this->assertInstanceOf(OwnerAggregate::class, $actual);
+ }
+
+ public function testGetEntityByPrimaryKeyWillThrowInvalidArgumentException(): void
+ {
+ $database = $this->createMock(Sql::class);
+ $metaFactory = $this->createMock(MetaFactory::class);
+
+ $unit = new Factory($database, $metaFactory);
+
+ $this->expectException(InvalidArgumentException::class);
+ $unit->getEntityByPrimaryKey('Subtext\\Persistables\\Foobar', 1);
+ }
+
+ public function testGetEntityCollectionWillAppendEntitiesByDefault(): void
+ {
+ $sql = <<createMock(Collection::class);
+ $collection->expects($this->once())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getQueryData')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo([]),
+ $this->equalTo(PDO::FETCH_CLASS),
+ $this->equalTo(SimpleEntity::class)
+ )->willReturn($expected);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+
+ $unit = new Factory($database, $metaFactory);
+ $actual = $unit->getEntityCollection($sql, $collection, []);
+
+ $this->assertSame($collection, $actual);
+
+ }
+
+ public function testGetEntityCollectionWillSetByEntityPrimaryKey(): void
+ {
+ $entity1 = $this->createMock(SimpleEntity::class);
+ $entity1->expects($this->once())
+ ->method('getId')
+ ->willReturn(5);
+ $entity2 = $this->createMock(SimpleEntity::class);
+ $entity2->expects($this->once())
+ ->method('getId')
+ ->willReturn(7);
+ $entity3 = $this->createMock(SimpleEntity::class);
+ $entity3->expects($this->once())
+ ->method('getId')
+ ->willReturn(9);
+
+ $expected = [$entity1, $entity2, $entity3];
+
+ $sql = <<createMock(Collection::class);
+ $collection->expects($this->once())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+ $collection->expects($this->exactly(3))
+ ->method('set')
+ ->willReturnCallback(function ($key, $value) use ($expected) {
+ static $counter = 0;
+ $counter++;
+ switch ($counter) {
+ case 1:
+ $index = 0;
+ $id = 5;
+ break;
+ case 2:
+ $index = 1;
+ $id = 7;
+ break;
+ case 3:
+ $index = 2;
+ $id = 9;
+ break;
+ }
+ $this->assertEquals($key, $id);
+ $this->assertEquals($expected[$index], $value);
+ });
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getQueryData')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo([]),
+ $this->equalTo(PDO::FETCH_CLASS),
+ $this->equalTo(SimpleEntity::class)
+ )->willReturn($expected);
+
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->exactly(3))
+ ->method('getTable')
+ ->willReturn(new Table('simple_entities', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(),
+ 'name' => new Column(),
+ ]));
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->exactly(3))
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+ $actual = $unit->getEntityCollection($sql, $collection, [], false);
+
+ $this->assertSame($collection, $actual);
+ }
+
+ public function testPersistWillInsert(): void
+ {
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('simple_entities', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(),
+ 'name' => new Column(),
+ ]));
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getInsertQuery')
+ ->willReturn('INSERT INTO `simple_entities` (`id`, `name`) VALUES (?, ?)');
+ $database->expects($this->once())
+ ->method('getIdForInsert')
+ ->willReturn(1);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->once())
+ ->method('setId')
+ ->with(1);
+
+ $unit = new Factory($database, $metaFactory);
+
+ $unit->persist($entity);
+ }
+
+ public function testPersistWillUpdate(): void
+ {
+ $sql = <<getMetaDataMock();
+
+ $modified = $this->createMock(Modifications\Collection::class);
+ $modified->expects($this->once())
+ ->method('count')
+ ->willReturn(1);
+ $modified->expects($this->once())
+ ->method('getNames')
+ ->willReturn(new Text(['id', 'name']));
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(1);
+ $entity->expects($this->once())
+ ->method('getName')
+ ->willReturn('beta');
+ $entity->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getUpdateQuery')
+ ->with(
+ $this->equalTo($meta),
+ $this->equalTo($modified)
+ )->willReturn($sql);
+ $database->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo(['beta', 1])
+ )->willReturn(true);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+ $unit->persist($entity);
+ }
+
+ public function testPersistWillInsertCollection(): void
+ {
+ $sql = <<getMetaDataMock();
+
+ $entity1 = $this->createMock(SimpleEntity::class);
+ $entity1->expects($this->once())
+ ->method('getId')
+ ->willReturn(null);
+ $entity1->expects($this->once())
+ ->method('getName')
+ ->willReturn('beta');
+ $entity1->expects($this->once())
+ ->method('setId');
+ $entity1->expects($this->once())
+ ->method('resetModified');
+ $entity2 = $this->createMock(SimpleEntity::class);
+ $entity2->expects($this->once())
+ ->method('getId')
+ ->willReturn(null);
+ $entity2->expects($this->once())
+ ->method('getName')
+ ->willReturn('delta');
+ $entity2->expects($this->once())
+ ->method('setId');
+ $entity2->expects($this->once())
+ ->method('resetModified');
+ $entity3 = $this->createMock(SimpleEntity::class);
+ $entity3->expects($this->once())
+ ->method('getId')
+ ->willReturn(null);
+ $entity3->expects($this->once())
+ ->method('getName')
+ ->willReturn('gamma');
+ $entity3->expects($this->once())
+ ->method('setId');
+ $entity3->expects($this->once())
+ ->method('resetModified');
+
+ $collection = $this->createMock(Collection::class);
+ $collection->expects($this->any())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+ $collection->expects($this->any())
+ ->method('count')
+ ->willReturn(3);
+ $collection->expects($this->any())
+ ->method('getIterator')
+ ->willReturn(new ArrayIterator([$entity1, $entity2, $entity3]));
+ $collection->expects($this->once())
+ ->method('reduce')
+ ->willReturn(true);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getInsertQuery')
+ ->with()->willReturn($sql);
+ $database->expects($this->once())
+ ->method('getIdForInsert')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo(['beta', 'delta', 'gamma']),
+ )->willReturn(3);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+ $unit->persist($collection);
+ }
+
+ public function testPersistWillUpdateCollection(): void
+ {
+ $sql = <<getMetaDataMock();
+
+ $modified = $this->createMock(Modifications\Collection::class);
+ $modified->expects($this->any())
+ ->method('count')
+ ->willReturn(1);
+ $modified->expects($this->any())
+ ->method('getNames')
+ ->willReturn(new Text(['id', 'name']));
+
+ $entity1 = $this->createMock(SimpleEntity::class);
+ $entity1->expects($this->once())
+ ->method('getId')
+ ->willReturn(5);
+ $entity1->expects($this->once())
+ ->method('getName')
+ ->willReturn('beta');
+ $entity1->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+ $entity1->expects($this->once())
+ ->method('resetModified');
+ $entity2 = $this->createMock(SimpleEntity::class);
+ $entity2->expects($this->once())
+ ->method('getId')
+ ->willReturn(7);
+ $entity2->expects($this->once())
+ ->method('getName')
+ ->willReturn('delta');
+ $entity2->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+ $entity2->expects($this->once())
+ ->method('resetModified');
+ $entity3 = $this->createMock(SimpleEntity::class);
+ $entity3->expects($this->once())
+ ->method('getId')
+ ->willReturn(9);
+ $entity3->expects($this->once())
+ ->method('getName')
+ ->willReturn('gamma');
+ $entity3->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+ $entity3->expects($this->once())
+ ->method('resetModified');
+
+ $collection = $this->createMock(Collection::class);
+ $collection->expects($this->any())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+ $collection->expects($this->any())
+ ->method('count')
+ ->willReturn(3);
+ $collection->expects($this->any())
+ ->method('getIterator')
+ ->willReturn(new ArrayIterator([$entity1, $entity2, $entity3]));
+ $collection->expects($this->exactly(2))
+ ->method('reduce')
+ ->willReturnCallback(function () {
+ static $count = 0;
+ $count++;
+ if ($count === 1) {
+ return false;
+ }
+ return true;
+ });
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->exactly(3))
+ ->method('getUpdateQuery')
+ ->with(
+ $this->equalTo($meta),
+ $this->equalTo($modified)
+ )->willReturn($sql);
+ $database->expects($this->exactly(3))
+ ->method('execute')
+ ->willReturn(true);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+
+ $unit->persist($collection);
+ }
+
+ public function testPersistWillUpdateOrInsertCollection(): void
+ {
+ $meta = $this->getMetaDataMock();
+
+ $modified = $this->createMock(Modifications\Collection::class);
+ $modified->expects($this->any())
+ ->method('count')
+ ->willReturn(1);
+ $modified->expects($this->any())
+ ->method('getNames')
+ ->willReturn(new Text(['id', 'name']));
+
+ $entity1 = $this->createMock(SimpleEntity::class);
+ $entity1->expects($this->any())
+ ->method('getId')
+ ->willReturn(5);
+ $entity1->expects($this->once())
+ ->method('getName')
+ ->willReturn('beta');
+ $entity1->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+ $entity1->expects($this->once())
+ ->method('resetModified');
+ $entity2 = $this->createMock(SimpleEntity::class);
+ $entity2->expects($this->once())
+ ->method('getId')
+ ->willReturn(null);
+ $entity2->expects($this->once())
+ ->method('getName')
+ ->willReturn('delta');
+ $entity2->expects($this->once())
+ ->method('setId')
+ ->with($this->equalTo(7));
+ $entity2->expects($this->once())
+ ->method('resetModified');
+
+ $collection = $this->createMock(Collection::class);
+ $collection->expects($this->any())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+ $collection->expects($this->any())
+ ->method('count')
+ ->willReturn(2);
+ $collection->expects($this->any())
+ ->method('getIterator')
+ ->willReturn(new ArrayIterator([$entity1, $entity2]));
+ $collection->expects($this->exactly(2))
+ ->method('reduce')
+ ->willReturn(false);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getUpdateQuery')
+ ->with(
+ $this->equalTo($meta),
+ $this->equalTo($modified)
+ )->willReturn('UPDATE `simple_entities` SET `name` = ? WHERE `id` = ?');
+ $database->expects($this->once())
+ ->method('getInsertQuery')
+ ->with()->willReturn('INSERT INTO `simple_entities` (`name`) VALUES (?)');
+ $database->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+ $database->expects($this->once())
+ ->method('getIdForInsert')
+ ->willReturn(7);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+
+ $unit->persist($collection);
+ }
+
+ public function testPersistWillRecursivelyPersistChildren(): void
+ {
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('complex_aggregates', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(getter: 'getId', setter: 'setId'),
+ 'entityId' => new Column(getter: 'getEntityId', setter: 'setEntityId'),
+ ]));
+ $meta->expects($this->any())
+ ->method('getPersistables')
+ ->willReturn(new Entities\Collection([
+ 'entity' => new Entity(
+ SimpleEntity::class,
+ 'entityId',
+ true,
+ getter: 'getEntity',
+ setter: 'setEntity',
+ order: PersistOrder::BEFORE
+ ),
+ 'children' => new Entity(
+ Children::class,
+ 'aggregateId',
+ collection: true,
+ getter: 'getChildren',
+ setter: 'setChildren'
+ ),
+ ]));
+
+ $entityMeta = $this->getMetaDataMock();
+
+ $childMeta = $this->createMock(Meta::class);
+ $childMeta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('child_entities'));
+ $childMeta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column(primary: true, getter: 'getId', setter: 'setId'),
+ 'name' => new Column(getter: 'getName', setter: 'setName'),
+ ]));
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(null);
+ $entity->expects($this->once())
+ ->method('setId');
+
+ $child1 = $this->createMock(ChildEntity::class);
+ $child1->expects($this->any())
+ ->method('getId')
+ ->willReturn(null);
+ $child2 = $this->createMock(ChildEntity::class);
+ $child2->expects($this->any())
+ ->method('getId')
+ ->willReturn(null);
+ $child3 = $this->createMock(ChildEntity::class);
+ $child3->expects($this->any())
+ ->method('getId')
+ ->willReturn(null);
+
+ $children = $this->createMock(Children::class);
+ $children->expects($this->once())
+ ->method('reduce')
+ ->willReturn(true);
+ $children->expects($this->any())
+ ->method('getEntityClass')
+ ->willReturn(ChildEntity::class);
+ $children->expects($this->any())
+ ->method('count')
+ ->willReturn(3);
+ $children->expects($this->any())
+ ->method('getIterator')
+ ->willReturn(new ArrayIterator([$child1, $child2, $child3]));
+
+ $aggregate = $this->createMock(ComplexAggregate::class);
+ $aggregate->expects($this->any())
+ ->method('getEntity')
+ ->willReturn($entity);
+ $aggregate->expects($this->any())
+ ->method('getChildren')
+ ->willReturn($children);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->exactly(3))
+ ->method('getIdForInsert')
+ ->willReturn(1);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturnCallback(function (string $name) use ($meta, $childMeta, $entityMeta) {
+ if (str_contains($name, 'ComplexAggregate')) {
+ $result = $meta;
+ } elseif (str_contains($name, 'SimpleEntity')) {
+ $result = $entityMeta;
+ } elseif (str_contains($name, 'ChildEntity')) {
+ $result = $childMeta;
+ } else {
+ $result = $entityMeta;
+ }
+ return $result;
+ });
+
+ $factory = new Factory($database, $metaFactory);
+
+ $factory->persist($aggregate);
+ }
+
+ public function testPersistInsertWillThrowRuntimeException(): void
+ {
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(null);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getIdForInsert')
+ ->willReturn(0);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($this->getMetaDataMock());
+
+ $unit = new Factory($database, $metaFactory);
+
+ $this->expectException(RuntimeException::class);
+ $unit->persist($entity);
+ }
+
+ public function testPersistUpdateWillThrowRuntimeException(): void
+ {
+ $modified = $this->createMock(Modifications\Collection::class);
+ $modified->expects($this->any())
+ ->method('count')
+ ->willReturn(1);
+ $modified->expects($this->any())
+ ->method('getNames')
+ ->willReturn(new Text(['id', 'name']));
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(7);
+ $entity->expects($this->any())
+ ->method('getModified')
+ ->willReturn($modified);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getUpdateQuery')
+ ->willReturn('UPDATE `simple_entities` SET `name` = ? WHERE `id` = ?');
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($this->getMetaDataMock());
+
+ $unit = new Factory($database, $metaFactory);
+
+ $this->expectException(RuntimeException::class);
+ $unit->persist($entity);
+ }
+
+ public function testDesist(): void
+ {
+ $sql = 'DELETE FROM `simple_entities` WHERE `id` = ?';
+ $meta = $this->getMetaDataMock();
+
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(5);
+ $entity->expects($this->any())
+ ->method('getName')
+ ->willReturn('delta');
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getDeleteQuery')
+ ->with(
+ $this->equalTo($meta),
+ $this->equalTo(1)
+ )->willReturn($sql);
+ $database->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo([5])
+ )->willReturn(true);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $unit = new Factory($database, $metaFactory);
+
+ $unit->desist($entity);
+ }
+
+ public function testDesistCollection(): void
+ {
+ $sql = 'DELETE FROM `simple_entities` WHERE `id` IN (?,?,?)';
+
+ $entity1 = $this->createMock(SimpleEntity::class);
+ $entity1->expects($this->any())
+ ->method('getId')
+ ->willReturn(5);
+ $entity2 = $this->createMock(SimpleEntity::class);
+ $entity2->expects($this->once())
+ ->method('getId')
+ ->willReturn(7);
+
+ $meta = $this->getMetaDataMock();
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getDeleteQuery')
+ ->willReturn($sql);
+ $database->expects($this->once())
+ ->method('execute')
+ ->willReturn(true);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($meta);
+
+ $collection = $this->createMock(Collection::class);
+ $collection->expects($this->any())
+ ->method('count')
+ ->willReturn(3);
+ $collection->expects($this->any())
+ ->method('getEntityClass')
+ ->willReturn(SimpleEntity::class);
+ $collection->expects($this->any())
+ ->method('getIterator')
+ ->willReturn(new ArrayIterator([$entity1, $entity2]));
+
+ $unit = new Factory($database, $metaFactory);
+
+ $unit->desist($collection);
+ }
+
+ public function testDesistWillThrowRuntimeException(): void
+ {
+ $sql = 'DELETE FROM `simple_entities` WHERE `id` = ?';
+ $entity = $this->createMock(SimpleEntity::class);
+ $entity->expects($this->any())
+ ->method('getId')
+ ->willReturn(5);
+
+ $database = $this->createMock(Sql::class);
+ $database->expects($this->once())
+ ->method('getDeleteQuery')
+ ->willReturn($sql);
+ $database->expects($this->once())
+ ->method('execute')
+ ->with(
+ $this->equalTo($sql),
+ $this->equalTo([5])
+ )->willReturn(false);
+
+ $metaFactory = $this->createMock(MetaFactory::class);
+ $metaFactory->expects($this->any())
+ ->method('get')
+ ->willReturn($this->getMetaDataMock());
+
+ $unit = new Factory($database, $metaFactory);
+
+ $this->expectException(RuntimeException::class);
+ $unit->desist($entity);
+ }
+
+ private function getMetaDataMock()
+ {
+ $meta = $this->createMock(Meta::class);
+ $meta->expects($this->any())
+ ->method('getTable')
+ ->willReturn(new Table('simple_entities', 'id'));
+ $meta->expects($this->any())
+ ->method('getColumns')
+ ->willReturn(new Columns\Collection([
+ 'id' => new Column('id', 'simple_entities', true, getter: 'getId', setter: 'setId'),
+ 'name' => new Column('name', 'simple_entities', getter: 'getName', setter: 'setName'),
+ ]));
+ $meta->expects($this->any())
+ ->method('getPersistables')
+ ->willReturn(null);
+ return $meta;
+ }
+}
diff --git a/tests/unit/Fixtures/BadAccessorEntity.php b/tests/unit/Fixtures/BadAccessorEntity.php
new file mode 100644
index 0000000..f118a75
--- /dev/null
+++ b/tests/unit/Fixtures/BadAccessorEntity.php
@@ -0,0 +1,25 @@
+ should throw
+ #[Column(name: "id", primary: true, setter: "nopeSet")]
+ protected ?int $id = null;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id];
+ }
+}
diff --git a/tests/unit/Fixtures/BadJoinType.php b/tests/unit/Fixtures/BadJoinType.php
new file mode 100644
index 0000000..20842a3
--- /dev/null
+++ b/tests/unit/Fixtures/BadJoinType.php
@@ -0,0 +1,33 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return (object) [
+ 'id' => $this->getId(),
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/BadUnionEntity.php b/tests/unit/Fixtures/BadUnionEntity.php
new file mode 100644
index 0000000..821367c
--- /dev/null
+++ b/tests/unit/Fixtures/BadUnionEntity.php
@@ -0,0 +1,46 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify("id", $id);
+ }
+
+ public function getX(): int|string
+ {
+ return $this->x;
+ }
+
+ public function setX(int|string $x): void
+ {
+ $this->modify("x", $x);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return [
+ "id" => $this->id,
+ "x" => $this->x
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/ChildEntity.php b/tests/unit/Fixtures/ChildEntity.php
new file mode 100644
index 0000000..0e07edd
--- /dev/null
+++ b/tests/unit/Fixtures/ChildEntity.php
@@ -0,0 +1,59 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): void
+ {
+ $this->modify('name', $name);
+ }
+
+ public function getAggregateId(): int
+ {
+ return $this->aggregateId;
+ }
+
+ public function setAggregateId(int $aggregateId): void
+ {
+ $this->modify('aggregateId', $aggregateId);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return (object) [
+ 'id' => $this->getId(),
+ 'name' => $this->getName(),
+ 'aggregateId' => $this->getAggregateId(),
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/Children.php b/tests/unit/Fixtures/Children.php
new file mode 100644
index 0000000..f30f716
--- /dev/null
+++ b/tests/unit/Fixtures/Children.php
@@ -0,0 +1,25 @@
+children = new Children();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setId(?int $id): self
+ {
+ $this->modify('id', $id);
+ foreach ($this->getChildren() as $child) {
+ $child->setAggregateId($id);
+ }
+ return $this;
+ }
+
+ public function getEntityId(): ?int
+ {
+ return $this->entityId;
+ }
+
+ public function setEntityId(?int $entityId): self
+ {
+ $this->modify('entityId', $entityId);
+ return $this;
+ }
+
+ public function getEntity(): ?SimpleEntity
+ {
+ return $this->entity;
+ }
+
+ public function setEntity(?SimpleEntity $entity): self
+ {
+ $this->entity = $entity;
+ if ($entity !== null) {
+ $this->modify('entityId', $entity->getId());
+ }
+ return $this;
+ }
+
+ public function getChildren(): Children
+ {
+ return $this->children;
+ }
+
+ public function setChildren(Children $children): self
+ {
+ $this->children = $children;
+ return $this;
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return (object) [
+ 'id' => $this->getId(),
+ 'entityId' => $this->getEntityId(),
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/MissingTable.php b/tests/unit/Fixtures/MissingTable.php
new file mode 100644
index 0000000..fcad331
--- /dev/null
+++ b/tests/unit/Fixtures/MissingTable.php
@@ -0,0 +1,25 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id];
+ }
+}
diff --git a/tests/unit/Fixtures/NoColumnsEntity.php b/tests/unit/Fixtures/NoColumnsEntity.php
new file mode 100644
index 0000000..98f0a72
--- /dev/null
+++ b/tests/unit/Fixtures/NoColumnsEntity.php
@@ -0,0 +1,17 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ $child = $this->getChild();
+ if ($child !== null) {
+ $child->setAggregateId($id);
+ }
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): void
+ {
+ $this->modify('name', $name);
+ }
+
+ public function getChild(): ?ChildEntity
+ {
+ return $this->child;
+ }
+
+ public function setChild(ChildEntity $child): void
+ {
+ $this->child = $child;
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return (object) [
+ 'id' => $this->getId(),
+ 'name' => $this->getName(),
+ 'child' => $this->getChild(),
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/ReadonlyColumnEntity.php b/tests/unit/Fixtures/ReadonlyColumnEntity.php
new file mode 100644
index 0000000..faf0fde
--- /dev/null
+++ b/tests/unit/Fixtures/ReadonlyColumnEntity.php
@@ -0,0 +1,38 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getCreatedAt(): string
+ {
+ return $this->createdAt;
+ }
+ // no setter expected (readonly)
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id, 'createdAt' => $this->createdAt];
+ }
+}
diff --git a/tests/unit/Fixtures/SimpleEntity.php b/tests/unit/Fixtures/SimpleEntity.php
new file mode 100644
index 0000000..6a31aa2
--- /dev/null
+++ b/tests/unit/Fixtures/SimpleEntity.php
@@ -0,0 +1,45 @@
+id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): void
+ {
+ $this->modify('name', $name);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return [
+ 'id' => $this->getId(),
+ 'name' => $this->getName(),
+ ];
+ }
+}
diff --git a/tests/unit/Fixtures/WithCustomAccessor.php b/tests/unit/Fixtures/WithCustomAccessor.php
new file mode 100644
index 0000000..df5ac8a
--- /dev/null
+++ b/tests/unit/Fixtures/WithCustomAccessor.php
@@ -0,0 +1,29 @@
+id;
+ }
+
+ public function storeId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id];
+ }
+}
diff --git a/tests/unit/Fixtures/WithEntityExplicit.php b/tests/unit/Fixtures/WithEntityExplicit.php
new file mode 100644
index 0000000..8298ca9
--- /dev/null
+++ b/tests/unit/Fixtures/WithEntityExplicit.php
@@ -0,0 +1,62 @@
+child = new SimpleEntity();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getEntityId(): ?int
+ {
+ return $this->entityId;
+ }
+
+ public function setEntityId(int $entityId): void
+ {
+ $this->modify('entityId', $entityId);
+ }
+
+ public function getChild(): SimpleEntity
+ {
+ return $this->child;
+ }
+
+ public function setChild(SimpleEntity $child): void
+ {
+ $this->modify('child', $child);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id, 'child' => $this->child->jsonSerialize()];
+ }
+}
diff --git a/tests/unit/Fixtures/WithEntityImplicitNullable.php b/tests/unit/Fixtures/WithEntityImplicitNullable.php
new file mode 100644
index 0000000..a94430b
--- /dev/null
+++ b/tests/unit/Fixtures/WithEntityImplicitNullable.php
@@ -0,0 +1,44 @@
+ true
+ #[Entity(foreign: "simple_entity_id")]
+ protected ?SimpleEntity $child = null;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getChild(): ?SimpleEntity
+ {
+ return $this->child;
+ }
+
+ public function setChild(?SimpleEntity $child): void
+ {
+ $this->modify('child', $child);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id, 'child' => $this->child?->jsonSerialize()];
+ }
+}
diff --git a/tests/unit/Fixtures/WithEntityUnion.php b/tests/unit/Fixtures/WithEntityUnion.php
new file mode 100644
index 0000000..6b6ee15
--- /dev/null
+++ b/tests/unit/Fixtures/WithEntityUnion.php
@@ -0,0 +1,49 @@
+child = new SimpleEntity();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getChild(): SimpleEntity|int
+ {
+ return $this->child;
+ }
+
+ public function setChild(SimpleEntity|int $child): void
+ {
+ $this->modify('child', $child);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return ['id' => $this->id, 'child' => is_object($this->child) ? $this->child->jsonSerialize() : $this->child];
+ }
+}
diff --git a/tests/unit/HydratorTest.php b/tests/unit/HydratorTest.php
new file mode 100644
index 0000000..87dc75d
--- /dev/null
+++ b/tests/unit/HydratorTest.php
@@ -0,0 +1,175 @@
+getIntegerValue($this->numeric);
+ }
+
+ public function getCountable(): mixed
+ {
+ return $this->getIntegerValue($this->countable);
+ }
+
+ public function getEmpty(): mixed
+ {
+ return $this->getIntegerValue($this->empty, false);
+ }
+
+ public function getNull(): mixed
+ {
+ return $this->getIntegerValue($this->empty);
+ }
+ };
+
+ $this->assertIsInt($unit->getNumeric());
+ $this->assertIsInt($unit->getCountable());
+ $this->assertIsInt($unit->getEmpty());
+ $this->assertNull($unit->getNull());
+ }
+
+ public function testHydrateDateTimeValue(): void
+ {
+ $datetime = new DateTime()->format(DateTimeInterface::ATOM);
+ $unit = new class ($datetime, $datetime, new DateTimeImmutable(), null, null) {
+ use Hydrator;
+
+ public function __construct(
+ private mixed $string,
+ private mixed $immutable,
+ private mixed $interface,
+ private mixed $empty,
+ private mixed $default
+ ) {
+ }
+
+ public function getString(): mixed
+ {
+ return $this->getDateValue($this->string, false);
+ }
+
+ public function getImmutable(): DateTimeImmutable
+ {
+ return $this->getDateValue($this->immutable, false, immutable: true);
+ }
+
+ public function getInterface(): mixed
+ {
+ return $this->getDateValue($this->interface);
+ }
+
+ public function getEmpty(): mixed
+ {
+ return $this->getDateValue($this->empty);
+ }
+
+ public function getDefault(): mixed
+ {
+ return $this->getDateValue($this->default, false);
+ }
+
+ public function getImmutableFromNull(): DateTimeImmutable
+ {
+ return $this->getDateValue(null, false, immutable: true);
+ }
+ };
+
+ $this->assertInstanceOf(DateTime::class, $unit->getString());
+ $this->assertInstanceOf(DateTimeImmutable::class, $unit->getImmutable());
+ $this->assertInstanceOf(DateTimeImmutable::class, $unit->getInterface());
+ $this->assertEquals(null, $unit->getEmpty());
+ $this->assertEquals(new DateTime('0000-00-00 00:00:00'), $unit->getDefault());
+ $this->assertEquals(new DateTimeImmutable('0000-00-00 00:00:00'), $unit->getImmutableFromNull());
+ }
+
+ public function testHydrateBooleanValue(): void
+ {
+ $unit = new class (5, 'false', true) {
+ use Hydrator;
+
+ public function __construct(
+ private string $numeric,
+ private string $string,
+ private bool $boolean
+ ) {
+ }
+
+ public function getNumeric(): bool
+ {
+ return $this->getBooleanValue($this->numeric);
+ }
+
+ public function getString(): bool
+ {
+ return $this->getBooleanValue($this->string);
+ }
+
+ public function setString(string $string): void
+ {
+ $this->string = $string;
+ }
+
+ public function getBoolean(): bool
+ {
+ return $this->getBooleanValue($this->boolean);
+ }
+ };
+
+ $this->assertTrue($unit->getNumeric());
+ $this->assertFalse($unit->getString());
+ $this->assertTrue($unit->getBoolean());
+
+ $unit->setString('Yes');
+ $this->assertTrue($unit->getString());
+ }
+
+ public function testHydrateJsonValue(): void
+ {
+ $expected = (object) [
+ ];
+
+ $unit = new class (json_encode($expected)) {
+ use Hydrator;
+ protected string $json;
+
+ public function __construct(string $json)
+ {
+ $this->json = $json;
+ }
+
+ public function getJson(): mixed
+ {
+ return $this->getJsonValue($this->json);
+ }
+
+ public function getNullOnError(): null
+ {
+ return $this->getJsonValue('');
+ }
+ };
+
+ $this->assertEquals($expected, $unit->getJson());
+ $this->assertEquals(null, $unit->getNullOnError());
+ }
+}
diff --git a/tests/unit/ModificationTest.php b/tests/unit/ModificationTest.php
new file mode 100644
index 0000000..f778a57
--- /dev/null
+++ b/tests/unit/ModificationTest.php
@@ -0,0 +1,22 @@
+assertEquals($name, $unit->getName());
+ $this->assertEquals($old, $unit->getOldValue());
+ $this->assertEquals($new, $unit->getNewValue());
+ }
+}
diff --git a/tests/unit/Modifications/CollectionTest.php b/tests/unit/Modifications/CollectionTest.php
new file mode 100644
index 0000000..69ba675
--- /dev/null
+++ b/tests/unit/Modifications/CollectionTest.php
@@ -0,0 +1,49 @@
+createMock(Modification::class);
+ $unit = new Collection();
+ $unit->set('x', $mock);
+
+ $this->assertSame($mock, $unit->get('x'));
+ $this->expectException(InvalidArgumentException::class);
+
+ $unit->append(0);
+ }
+
+ public function testCanGetModificationNames(): void
+ {
+ $expected = new Text(['a', 'b', 'c']);
+ $mod1 = $this->createMock(Modification::class);
+ $mod1->expects($this->once())
+ ->method('getName')
+ ->willReturn('a');
+ $mod2 = $this->createMock(Modification::class);
+ $mod2->expects($this->once())
+ ->method('getName')
+ ->willReturn('b');
+ $mod3 = $this->createMock(Modification::class);
+ $mod3->expects($this->once())
+ ->method('getName')
+ ->willReturn('c');
+
+ $unit = new Collection([$mod1, $mod2, $mod3]);
+
+ $actual = $unit->getNames();
+
+ $this->assertEquals($expected, $actual);
+ }
+}
diff --git a/tests/unit/PersistableTest.php b/tests/unit/PersistableTest.php
new file mode 100644
index 0000000..3a2a114
--- /dev/null
+++ b/tests/unit/PersistableTest.php
@@ -0,0 +1,86 @@
+id = $id;
+ $this->alpha = $alpha;
+ $this->beta = $beta;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setId(?int $id): void
+ {
+ $this->modify('id', $id);
+ }
+
+ public function getAlpha(): string
+ {
+ return $this->alpha;
+ }
+
+ public function setAlpha(string $alpha): void
+ {
+ $this->modify('alpha', $alpha);
+ }
+
+ public function getBeta(): string
+ {
+ return $this->beta;
+ }
+
+ public function setBeta(string $beta): void
+ {
+ $this->modify('beta', $beta);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return (object) [
+ 'id' => $this->getId(),
+ 'alpha' => $this->getAlpha(),
+ 'beta' => $this->getBeta(),
+ ];
+ }
+ };
+
+ $this->assertCount(0, $unit->getModified());
+
+ $unit->setAlpha('apple');
+ $unit->setBeta('banana');
+
+ $modifications = $unit->getModified();
+
+ $this->assertCount(2, $modifications);
+ $this->assertEquals('a', $modifications->getFirst()->getOldValue());
+ $this->assertEquals('apple', $modifications->getFirst()->getNewValue());
+ $this->assertEquals('b', $modifications->getLast()->getOldValue());
+ $this->assertEquals('banana', $modifications->getLast()->getNewValue());
+
+ $this->assertEquals('apple', $unit->getAlpha());
+ $this->assertEquals('banana', $unit->getBeta());
+ $this->assertEquals(1, $unit->getId());
+
+ $unit->rollbackModifications();
+ $this->assertCount(0, $unit->getModified());
+ $this->assertEquals('a', $unit->getAlpha());
+ $this->assertEquals('b', $unit->getBeta());
+ }
+}
diff --git a/tests/unit/Queries/CollectionTest.php b/tests/unit/Queries/CollectionTest.php
new file mode 100644
index 0000000..58a8a92
--- /dev/null
+++ b/tests/unit/Queries/CollectionTest.php
@@ -0,0 +1,25 @@
+createMock(SqlCommand::class);
+
+ $unit = new Collection();
+ $unit->append($mock);
+
+ $this->assertCount(1, $unit);
+ $this->expectException(InvalidArgumentException::class);
+
+ $unit->append(new stdClass());
+ }
+}
diff --git a/tests/unit/Queries/SqlCommandTest.php b/tests/unit/Queries/SqlCommandTest.php
new file mode 100644
index 0000000..ec2ecbd
--- /dev/null
+++ b/tests/unit/Queries/SqlCommandTest.php
@@ -0,0 +1,20 @@
+assertEquals($query, $unit->getQuery());
+ $this->assertEquals($data, $unit->getData());
+ }
+}