From ce221307e5f294430738bf865a5b7198721ed609 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 15 Aug 2025 09:32:53 -0500 Subject: [PATCH 01/12] init modifications in the getter --- src/Persistable.php | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) 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(); - } - } } From fed8dad57f4ab73ea0a0ba78c1532afa380c72f0 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 29 Aug 2025 10:26:36 -0500 Subject: [PATCH 02/12] add some basic unit tests --- README.md | 12 +- phpunit.xml | 20 + src/Databases/Connections/Auth.php | 15 + src/Databases/Connections/MySql.php | 40 +- src/Databases/Error.php | 13 + src/Databases/Meta/Factory.php | 4 +- src/Databases/Sql.php | 62 +- .../SqlGenerators/MySqlGenerator.php | 30 +- .../unit/Databases/Attributes/ColumnTest.php | 21 + .../Attributes/Columns/CollectionTest.php | 17 + .../Attributes/Entities/CollectionTest.php | 17 + .../unit/Databases/Attributes/EntityTest.php | 30 + tests/unit/Databases/Attributes/JoinTest.php | 27 + .../Attributes/Joins/CollectionTest.php | 17 + tests/unit/Databases/Attributes/TableTest.php | 17 + .../unit/Databases/Connections/MySqlTest.php | 34 + tests/unit/Databases/Meta/CollectionTest.php | 25 + tests/unit/Databases/MetaTest.php | 28 + .../SqlGenerators/MySqlGeneratorTest.php | 217 ++++++ tests/unit/Databases/SqlTest.php | 702 ++++++++++++++++++ 20 files changed, 1278 insertions(+), 70 deletions(-) create mode 100644 phpunit.xml create mode 100644 src/Databases/Connections/Auth.php create mode 100644 src/Databases/Error.php create mode 100644 tests/unit/Databases/Attributes/ColumnTest.php create mode 100644 tests/unit/Databases/Attributes/Columns/CollectionTest.php create mode 100644 tests/unit/Databases/Attributes/Entities/CollectionTest.php create mode 100644 tests/unit/Databases/Attributes/EntityTest.php create mode 100644 tests/unit/Databases/Attributes/JoinTest.php create mode 100644 tests/unit/Databases/Attributes/Joins/CollectionTest.php create mode 100644 tests/unit/Databases/Attributes/TableTest.php create mode 100644 tests/unit/Databases/Connections/MySqlTest.php create mode 100644 tests/unit/Databases/Meta/CollectionTest.php create mode 100644 tests/unit/Databases/MetaTest.php create mode 100644 tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php create mode 100644 tests/unit/Databases/SqlTest.php diff --git a/README.md b/README.md index 3c9e912..c7e9521 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,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 +76,6 @@ class User extends Persistable 'userName' => $this->getUserName(), 'email' => $this->getEmail(), ]; - } - - public function getPersistables(): ?Collection - { - return null; - } - + } } ``` diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..537c108 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests/unit + + + + + src + + + diff --git a/src/Databases/Connections/Auth.php b/src/Databases/Connections/Auth.php new file mode 100644 index 0000000..a95cff3 --- /dev/null +++ b/src/Databases/Connections/Auth.php @@ -0,0 +1,15 @@ +database; + $host = $auth->hostname; + $user = $auth->username; + $pass = $auth->password; + $char = $auth->charset; + } self::$instance = new self(new PDO( - "mysql:dbname={$name};host={$host};charset=utf8mb4", + "mysql:dbname=$name;host=$host;charset=$char", $user, $pass, )); } - } catch (PDOException $e) { - throw new RuntimeException( - 'The MySql connection could not be established.', - $e->getCode(), - $e - ); + } catch (PDOException) { + self::$instance = new self(null); } finally { return self::$instance; } @@ -53,6 +60,11 @@ public static function getInstance(): self public function getPdo(): PDO { + if ($this->pdo === null) { + throw new RuntimeException( + 'MySql PDO is not initialized, check credentials.' + ); + } return $this->pdo; } 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; diff --git a/src/Databases/Sql.php b/src/Databases/Sql.php index 003ddcd..37b2078 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(); } } @@ -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..39483bc 100644 --- a/src/Databases/SqlGenerators/MySqlGenerator.php +++ b/src/Databases/SqlGenerators/MySqlGenerator.php @@ -12,10 +12,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() @@ -45,7 +45,7 @@ public function getSelectQuery(Meta $meta, ?string $clause = null): string $tableName = $column->table ?? $table->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,11 +59,11 @@ 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) ); } @@ -73,16 +73,20 @@ public function getInsertQuery(Meta $meta, int $rows = 1): string $columns = $meta->getColumns()->filter(function (Column $column) { return $column->readonly === false; }); + $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 +126,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; } /** 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/Connections/MySqlTest.php b/tests/unit/Databases/Connections/MySqlTest.php new file mode 100644 index 0000000..e5ce594 --- /dev/null +++ b/tests/unit/Databases/Connections/MySqlTest.php @@ -0,0 +1,34 @@ +assertInstanceOf(Connection::class, $unit); + $this->assertInstanceOf(PDO::class, $unit->getPdo()); + $this->assertInstanceOf(SqlGenerator::class, $unit->getSqlGenerator()); + + $actual = MySql::getInstance(); + $this->assertSame($unit, $actual); + } + + public function testWillThrowException(): void + { + $unit = MySql::getInstance(new Auth('', '', '', '', ''), true); + + $this->expectException(RuntimeException::class); + $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/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..270afd4 --- /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..2aa6edb --- /dev/null +++ b/tests/unit/Databases/SqlTest.php @@ -0,0 +1,702 @@ +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 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`"; + $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`"; + $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); + $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->stmt + ->expects($this->exactly(2)) + ->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 + { + $expected = "'whatever'"; + + $this->connection->expects($this->once()) + ->method('getPdo') + ->willReturn($this->pdo); + + $unit = Sql::getInstance($this->connection, true); + $actual = $unit->quote("whatever"); + + $this->assertEquals($expected, $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); + } +} From bf6ab4986ea12ea6be697102b6f23d4386b7e32b Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 29 Aug 2025 11:24:51 -0500 Subject: [PATCH 03/12] add meta factory tests, and lint --- src/Databases/Meta/Factory.php | 6 - tests/unit/Databases/Meta/FactoryTest.php | 130 ++++++++++++++++++ .../SqlGenerators/MySqlGeneratorTest.php | 2 +- tests/unit/Fixtures/BadAccessorEntity.php | 25 ++++ tests/unit/Fixtures/BadJoinType.php | 33 +++++ tests/unit/Fixtures/BadUnionEntity.php | 46 +++++++ tests/unit/Fixtures/MissingTable.php | 25 ++++ tests/unit/Fixtures/NoColumnsEntity.php | 17 +++ tests/unit/Fixtures/ReadonlyColumnEntity.php | 38 +++++ tests/unit/Fixtures/SimpleEntity.php | 42 ++++++ tests/unit/Fixtures/WithCustomAccessor.php | 29 ++++ tests/unit/Fixtures/WithEntityExplicit.php | 48 +++++++ .../Fixtures/WithEntityImplicitNullable.php | 44 ++++++ tests/unit/Fixtures/WithEntityUnion.php | 49 +++++++ 14 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 tests/unit/Databases/Meta/FactoryTest.php create mode 100644 tests/unit/Fixtures/BadAccessorEntity.php create mode 100644 tests/unit/Fixtures/BadJoinType.php create mode 100644 tests/unit/Fixtures/BadUnionEntity.php create mode 100644 tests/unit/Fixtures/MissingTable.php create mode 100644 tests/unit/Fixtures/NoColumnsEntity.php create mode 100644 tests/unit/Fixtures/ReadonlyColumnEntity.php create mode 100644 tests/unit/Fixtures/SimpleEntity.php create mode 100644 tests/unit/Fixtures/WithCustomAccessor.php create mode 100644 tests/unit/Fixtures/WithEntityExplicit.php create mode 100644 tests/unit/Fixtures/WithEntityImplicitNullable.php create mode 100644 tests/unit/Fixtures/WithEntityUnion.php diff --git a/src/Databases/Meta/Factory.php b/src/Databases/Meta/Factory.php index 68fb946..3dd4c61 100644 --- a/src/Databases/Meta/Factory.php +++ b/src/Databases/Meta/Factory.php @@ -21,7 +21,6 @@ class Factory { - public const array ACCESSOR = ['get', 'set']; private static ?self $instance = null; private Collection $meta; @@ -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; diff --git a/tests/unit/Databases/Meta/FactoryTest.php b/tests/unit/Databases/Meta/FactoryTest.php new file mode 100644 index 0000000..0a8c6c9 --- /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_entity', $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('simple_entity_id', $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/SqlGenerators/MySqlGeneratorTest.php b/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php index 270afd4..6254135 100644 --- a/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php +++ b/tests/unit/Databases/SqlGenerators/MySqlGeneratorTest.php @@ -77,7 +77,7 @@ public function testGetSelectQueryWithJoins(): void null ); - $unit = MySqlGenerator::getInstance(); + $unit = MySqlGenerator::getInstance(); $actual = $unit->getSelectQuery($meta); $this->assertEquals($expected, $actual); 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/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); + } + + 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..c33e96a --- /dev/null +++ b/tests/unit/Fixtures/SimpleEntity.php @@ -0,0 +1,42 @@ +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->id, 'name' => $this->name]; + } +} 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..ca5cdb7 --- /dev/null +++ b/tests/unit/Fixtures/WithEntityExplicit.php @@ -0,0 +1,48 @@ +child = new SimpleEntity(); + } + + 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/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]; + } +} From d8530f7f764ea36268f41b3a9d885587dd57630c Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Tue, 2 Sep 2025 12:30:19 -0500 Subject: [PATCH 04/12] add more unit tests --- src/Databases/Sql.php | 2 +- src/Hydrator.php | 20 --- src/Modifications/Collection.php | 3 +- tests/unit/CollectionTest.php | 30 ++++ tests/unit/Databases/SqlTest.php | 56 ++++++- tests/unit/HydratorTest.php | 177 ++++++++++++++++++++ tests/unit/ModificationTest.php | 22 +++ tests/unit/Modifications/CollectionTest.php | 49 ++++++ tests/unit/PersistableTest.php | 86 ++++++++++ tests/unit/Queries/CollectionTest.php | 25 +++ tests/unit/Queries/SqlCommandTest.php | 20 +++ 11 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 tests/unit/CollectionTest.php create mode 100644 tests/unit/HydratorTest.php create mode 100644 tests/unit/ModificationTest.php create mode 100644 tests/unit/Modifications/CollectionTest.php create mode 100644 tests/unit/PersistableTest.php create mode 100644 tests/unit/Queries/CollectionTest.php create mode 100644 tests/unit/Queries/SqlCommandTest.php diff --git a/src/Databases/Sql.php b/src/Databases/Sql.php index 37b2078..f5f190e 100644 --- a/src/Databases/Sql.php +++ b/src/Databases/Sql.php @@ -410,7 +410,7 @@ private function getResultFromDatabase( default: $result = false; } - if ($result === false) { + if (($result ?? false) === false) { $result = $this->getDefaultResult($type); } } catch (PDOException $e) { 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/tests/unit/CollectionTest.php b/tests/unit/CollectionTest.php new file mode 100644 index 0000000..18044fd --- /dev/null +++ b/tests/unit/CollectionTest.php @@ -0,0 +1,30 @@ +append($expected); + + $this->assertSame($expected, $unit->getFirst()); + $this->expectException(InvalidArgumentException::class); + + $unit->append($this->createMock(SimpleEntity::class)); + } +} diff --git a/tests/unit/Databases/SqlTest.php b/tests/unit/Databases/SqlTest.php index 2aa6edb..ffbd3e4 100644 --- a/tests/unit/Databases/SqlTest.php +++ b/tests/unit/Databases/SqlTest.php @@ -194,6 +194,25 @@ public function testCanGetFloatWithGetQueryResult(): void $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"; @@ -497,13 +516,7 @@ public function testExecuteTransactionWillRollbackOnError(): void $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]); + $query2 = $this->createMock(SqlCommand::class); $collection = $this->createMock(Queries\Collection::class); $collection->method('getIterator') ->willReturn(new ArrayIterator([$query1, $query2])); @@ -521,7 +534,7 @@ public function testExecuteTransactionWillRollbackOnError(): void ->expects($this->once()) ->method('rollback'); $this->stmt - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('execute') ->willReturn(false); @@ -614,18 +627,43 @@ public function testExecuteWillHandlePDOExceptions(): void 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("whatever"); + $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()) diff --git a/tests/unit/HydratorTest.php b/tests/unit/HydratorTest.php new file mode 100644 index 0000000..3475d12 --- /dev/null +++ b/tests/unit/HydratorTest.php @@ -0,0 +1,177 @@ +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()); + } +} From 9b94f4548f062bd78b71b6c05851506097aee229 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Wed, 3 Sep 2025 11:15:25 -0500 Subject: [PATCH 05/12] add incomplete tests for factory --- src/Factory.php | 60 +- tests/unit/Databases/Meta/FactoryTest.php | 2 +- tests/unit/FactoryTest.php | 798 +++++++++++++++++++++ tests/unit/Fixtures/WithEntityExplicit.php | 16 +- tests/unit/HydratorTest.php | 4 +- 5 files changed, 842 insertions(+), 38 deletions(-) create mode 100644 tests/unit/FactoryTest.php diff --git a/src/Factory.php b/src/Factory.php index 0c4f3cd..e7802ac 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -2,6 +2,7 @@ namespace Subtext\Persistables; +use Closure; use InvalidArgumentException; use PDO; use ReflectionException; @@ -15,12 +16,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 +32,7 @@ public function __construct(Sql $db) * * @return Persistable|null * @throws ReflectionException + * @throws InvalidArgumentException */ public function getEntityByPrimaryKey(string $entity, mixed $primaryKeyValue): ?Persistable { @@ -39,7 +41,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 +90,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,7 +142,10 @@ 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']), + false + ); } else { $isInsert = $this->isPersistableInsert($persistable); } @@ -158,7 +163,10 @@ 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']), + false + ); } else { $isUpdate = $this->isPersistableUpdate($persistable); } @@ -178,13 +186,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 +213,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 +253,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 +269,7 @@ protected function performSingleUpdate(Persistable $object): void 'The database records could not be updated' ); } + $object->resetModified(); } } @@ -279,13 +287,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))) { @@ -377,7 +385,7 @@ protected function getDbParams( */ protected function getPrimaryKey(Persistable $object): string { - return $this->getMeta($object::class)->getTable()->primaryKey; + return $this->meta->get($object::class)->getTable()->primaryKey; } /** @@ -421,20 +429,6 @@ 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. * @@ -533,7 +527,7 @@ protected function validate(mixed $value): void private function recursivelyHydrate(Persistable $persistable, Meta $meta): void { foreach ($meta->getPersistables() ?? [] as $entity) { - $childMeta = $this->getMeta($entity->class); + $childMeta = $this->meta->get($entity->class); if ($entity->order === PersistOrder::BEFORE) { $primaryKey = $entity->foreign ?? $childMeta->getTable()->primaryKey; $getter = $this->accessorName($persistable, $primaryKey); @@ -585,8 +579,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/tests/unit/Databases/Meta/FactoryTest.php b/tests/unit/Databases/Meta/FactoryTest.php index 0a8c6c9..7072d43 100644 --- a/tests/unit/Databases/Meta/FactoryTest.php +++ b/tests/unit/Databases/Meta/FactoryTest.php @@ -101,7 +101,7 @@ public function testEntityExplicitClassAndForeignAndAccessors(): void $e = $ents->get('child'); $this->assertSame(SimpleEntity::class, $e->class); - $this->assertSame('simple_entity_id', $e->foreign); + $this->assertSame('entityId', $e->foreign); $this->assertFalse($e->nullable); $this->assertSame('getChild', $e->getter); $this->assertSame('setChild', $e->setter); diff --git a/tests/unit/FactoryTest.php b/tests/unit/FactoryTest.php new file mode 100644 index 0000000..17dc52f --- /dev/null +++ b/tests/unit/FactoryTest.php @@ -0,0 +1,798 @@ +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_entity`.`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 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_entity', 'id')); + + $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_entity', 'id')); + + $database = $this->createMock(Sql::class); + $database->expects($this->once()) + ->method('getInsertQuery') + ->willReturn('INSERT INTO `simple_entity` (`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_entity` SET `name` = ? WHERE `id` = ?'); + $database->expects($this->once()) + ->method('getInsertQuery') + ->with()->willReturn('INSERT INTO `simple_entity` (`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 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_entity` 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_entity` 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_entity` 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_entity` 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_entity', 'id')); + $meta->expects($this->any()) + ->method('getColumns') + ->willReturn(new Columns\Collection([ + 'id' => new Column('id', 'simple_entity', true, getter: 'getId', setter: 'setId'), + 'name' => new Column('name', 'simple_entity', getter: 'getName', setter: 'setName'), + ])); + $meta->expects($this->any()) + ->method('getPersistables') + ->willReturn(null); + return $meta; + } +} diff --git a/tests/unit/Fixtures/WithEntityExplicit.php b/tests/unit/Fixtures/WithEntityExplicit.php index ca5cdb7..8298ca9 100644 --- a/tests/unit/Fixtures/WithEntityExplicit.php +++ b/tests/unit/Fixtures/WithEntityExplicit.php @@ -2,6 +2,7 @@ namespace Subtext\Persistables\Tests\Unit\Fixtures; +use Subtext\Persistables\Databases\Attributes\PersistOrder; use Subtext\Persistables\Persistable; use Subtext\Persistables\Databases\Attributes\Table; use Subtext\Persistables\Databases\Attributes\Column; @@ -13,7 +14,10 @@ class WithEntityExplicit extends Persistable #[Column(name: "id", primary: true)] protected ?int $id = null; - #[Entity(class: SimpleEntity::class, foreign: "simple_entity_id")] + #[Column('simple_entity_id')] + protected ?int $entityId = null; + + #[Entity(class: SimpleEntity::class, foreign: "entityId", order: PersistOrder::BEFORE)] protected SimpleEntity $child; public function __construct() @@ -31,6 +35,16 @@ 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; diff --git a/tests/unit/HydratorTest.php b/tests/unit/HydratorTest.php index 3475d12..87dc75d 100644 --- a/tests/unit/HydratorTest.php +++ b/tests/unit/HydratorTest.php @@ -147,12 +147,10 @@ public function getBoolean(): bool public function testHydrateJsonValue(): void { $expected = (object) [ - ]; - $unit = new class(json_encode($expected)) { + $unit = new class (json_encode($expected)) { use Hydrator; - protected string $json; public function __construct(string $json) From ce3b871a86b585e20359e700a2c1d40394bd12df Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Wed, 3 Sep 2025 13:21:10 -0500 Subject: [PATCH 06/12] update simple entity table name --- tests/unit/Databases/Meta/FactoryTest.php | 2 +- tests/unit/FactoryTest.php | 64 +++++++++++------------ tests/unit/Fixtures/SimpleEntity.php | 2 +- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/unit/Databases/Meta/FactoryTest.php b/tests/unit/Databases/Meta/FactoryTest.php index 7072d43..1340c63 100644 --- a/tests/unit/Databases/Meta/FactoryTest.php +++ b/tests/unit/Databases/Meta/FactoryTest.php @@ -53,7 +53,7 @@ public function testSimpleEntityColumnsAndAccessorsAreInferred(): void { $meta = MetaFactory::getInstance(true)->get(SimpleEntity::class); - $this->assertSame('simple_entity', $meta->getTable()->name); + $this->assertSame('simple_entities', $meta->getTable()->name); $cols = $meta->getColumns(); $this->assertTrue($cols->has('id')); $this->assertTrue($cols->has('name')); diff --git a/tests/unit/FactoryTest.php b/tests/unit/FactoryTest.php index 17dc52f..3d34d67 100644 --- a/tests/unit/FactoryTest.php +++ b/tests/unit/FactoryTest.php @@ -34,10 +34,10 @@ public function testGetEntityByPrimaryKey(): void $expected->resetModified(); $sql = <<createMock(Meta::class); @@ -76,10 +76,10 @@ public function testGetEntityByPrimaryKeyWithAggregate(): void SQL; $childSql = <<createMock(SimpleEntity::class); @@ -120,7 +120,7 @@ public function testGetEntityByPrimaryKeyWithAggregate(): void ->method('getSelectQuery') ->willReturnCallback(function ($class, $clause) use ($sql, $childSql) { if ($class === SimpleEntity::class) { - $this->assertEquals('`simple_entity`.`id` = ?', $clause); + $this->assertEquals('`simple_entities`.`id` = ?', $clause); return $childSql; } $this->assertEquals(null, $clause); @@ -167,10 +167,10 @@ public function testGetEntityByPrimaryKeyWillThrowInvalidArgumentException(): vo public function testGetEntityCollectionWillAppendEntitiesByDefault(): void { $sql = <<createMock(Collection::class); @@ -262,7 +262,7 @@ public function testGetEntityCollectionWillSetByEntityPrimaryKey(): void $meta = $this->createMock(Meta::class); $meta->expects($this->exactly(3)) ->method('getTable') - ->willReturn(new Table('simple_entity', 'id')); + ->willReturn(new Table('simple_entities', 'id')); $metaFactory = $this->createMock(MetaFactory::class); $metaFactory->expects($this->exactly(3)) @@ -280,12 +280,12 @@ public function testPersistWillInsert(): void $meta = $this->createMock(Meta::class); $meta->expects($this->any()) ->method('getTable') - ->willReturn(new Table('simple_entity', 'id')); + ->willReturn(new Table('simple_entities', 'id')); $database = $this->createMock(Sql::class); $database->expects($this->once()) ->method('getInsertQuery') - ->willReturn('INSERT INTO `simple_entity` (`id`, `name`) VALUES (?, ?)'); + ->willReturn('INSERT INTO `simple_entities` (`id`, `name`) VALUES (?, ?)'); $database->expects($this->once()) ->method('getIdForInsert') ->willReturn(1); @@ -308,7 +308,7 @@ public function testPersistWillInsert(): void public function testPersistWillUpdate(): void { $sql = <<with( $this->equalTo($meta), $this->equalTo($modified) - )->willReturn('UPDATE `simple_entity` SET `name` = ? WHERE `id` = ?'); + )->willReturn('UPDATE `simple_entities` SET `name` = ? WHERE `id` = ?'); $database->expects($this->once()) ->method('getInsertQuery') - ->with()->willReturn('INSERT INTO `simple_entity` (`name`) VALUES (?)'); + ->with()->willReturn('INSERT INTO `simple_entities` (`name`) VALUES (?)'); $database->expects($this->once()) ->method('execute') ->willReturn(true); @@ -654,7 +654,7 @@ public function testPersistUpdateWillThrowRuntimeException(): void $database = $this->createMock(Sql::class); $database->expects($this->once()) ->method('getUpdateQuery') - ->willReturn('UPDATE `simple_entity` SET `name` = ? WHERE `id` = ?'); + ->willReturn('UPDATE `simple_entities` SET `name` = ? WHERE `id` = ?'); $metaFactory = $this->createMock(MetaFactory::class); $metaFactory->expects($this->any()) @@ -669,7 +669,7 @@ public function testPersistUpdateWillThrowRuntimeException(): void public function testDesist(): void { - $sql = 'DELETE FROM `simple_entity` WHERE `id` = ?'; + $sql = 'DELETE FROM `simple_entities` WHERE `id` = ?'; $meta = $this->getMetaDataMock(); $entity = $this->createMock(SimpleEntity::class); @@ -706,7 +706,7 @@ public function testDesist(): void public function testDesistCollection(): void { - $sql = 'DELETE FROM `simple_entity` WHERE `id` IN (?,?,?)'; + $sql = 'DELETE FROM `simple_entities` WHERE `id` IN (?,?,?)'; $entity1 = $this->createMock(SimpleEntity::class); $entity1->expects($this->any()) @@ -750,7 +750,7 @@ public function testDesistCollection(): void public function testDesistWillThrowRuntimeException(): void { - $sql = 'DELETE FROM `simple_entity` WHERE `id` = ?'; + $sql = 'DELETE FROM `simple_entities` WHERE `id` = ?'; $entity = $this->createMock(SimpleEntity::class); $entity->expects($this->any()) ->method('getId') @@ -783,12 +783,12 @@ private function getMetaDataMock() $meta = $this->createMock(Meta::class); $meta->expects($this->any()) ->method('getTable') - ->willReturn(new Table('simple_entity', 'id')); + ->willReturn(new Table('simple_entities', 'id')); $meta->expects($this->any()) ->method('getColumns') ->willReturn(new Columns\Collection([ - 'id' => new Column('id', 'simple_entity', true, getter: 'getId', setter: 'setId'), - 'name' => new Column('name', 'simple_entity', getter: 'getName', setter: 'setName'), + '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') diff --git a/tests/unit/Fixtures/SimpleEntity.php b/tests/unit/Fixtures/SimpleEntity.php index c33e96a..609df78 100644 --- a/tests/unit/Fixtures/SimpleEntity.php +++ b/tests/unit/Fixtures/SimpleEntity.php @@ -6,7 +6,7 @@ use Subtext\Persistables\Databases\Attributes\Table; use Subtext\Persistables\Databases\Attributes\Column; -#[Table(name: "simple_entity", primaryKey: "id")] +#[Table(name: "simple_entities", primaryKey: "id")] class SimpleEntity extends Persistable { #[Column(name: "id", primary: true)] From 31a4306bb8ffdbf4e8693d35974e096a8e9a6279 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Thu, 4 Sep 2025 10:12:55 -0500 Subject: [PATCH 07/12] add tests for complex aggregates --- src/Collection.php | 2 +- src/Databases/Meta/Factory.php | 2 +- .../SqlGenerators/MySqlGenerator.php | 23 +- src/Factory.php | 53 +++- tests/unit/CollectionTest.php | 3 +- tests/unit/FactoryTest.php | 233 ++++++++++++++++++ tests/unit/Fixtures/ChildEntity.php | 59 +++++ tests/unit/Fixtures/Children.php | 25 ++ tests/unit/Fixtures/ComplexAggregate.php | 88 +++++++ tests/unit/Fixtures/SimpleEntity.php | 5 +- 10 files changed, 472 insertions(+), 21 deletions(-) create mode 100644 tests/unit/Fixtures/ChildEntity.php create mode 100644 tests/unit/Fixtures/Children.php create mode 100644 tests/unit/Fixtures/ComplexAggregate.php 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/Databases/Meta/Factory.php b/src/Databases/Meta/Factory.php index 3dd4c61..5c3cfc6 100644 --- a/src/Databases/Meta/Factory.php +++ b/src/Databases/Meta/Factory.php @@ -203,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/SqlGenerators/MySqlGenerator.php b/src/Databases/SqlGenerators/MySqlGenerator.php index 39483bc..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; @@ -43,7 +45,7 @@ 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 ?? $property) ); @@ -70,9 +72,13 @@ public function getSelectQuery(Meta $meta, ?string $clause = null): string 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); @@ -233,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 e7802ac..c050ff7 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -144,10 +144,10 @@ protected function isDbInsert(Persistable|Collection $persistable): bool if ($persistable instanceof Collection) { $isInsert = $persistable->reduce( Closure::fromCallable([$this, 'isPersistableInsert']), - false + true ); } else { - $isInsert = $this->isPersistableInsert($persistable); + $isInsert = $this->isPersistableInsert(true, $persistable); } return $isInsert; } @@ -165,10 +165,10 @@ protected function isDbUpdate(Persistable|Collection $persistable): bool if ($persistable instanceof Collection) { $isUpdate = $persistable->reduce( Closure::fromCallable([$this, 'isPersistableUpdate']), - false + true ); } else { - $isUpdate = $this->isPersistableUpdate($persistable); + $isUpdate = $this->isPersistableUpdate(true, $persistable); } return $isUpdate; } @@ -355,7 +355,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); @@ -364,7 +364,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); @@ -385,7 +385,26 @@ protected function getDbParams( */ protected function getPrimaryKey(Persistable $object): string { - return $this->meta->get($object::class)->getTable()->primaryKey; + return $this->resolvePrimaryKeyName($this->meta->get($object::class)); + } + + 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 ?? ''; } /** @@ -432,13 +451,13 @@ protected function setPrimaryKeyValue(Persistable $object, mixed $value): void /** * 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)); @@ -456,7 +475,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))) { @@ -474,7 +493,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; @@ -527,7 +545,12 @@ protected function validate(mixed $value): void private function recursivelyHydrate(Persistable $persistable, Meta $meta): void { foreach ($meta->getPersistables() ?? [] as $entity) { - $childMeta = $this->meta->get($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); @@ -543,7 +566,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, diff --git a/tests/unit/CollectionTest.php b/tests/unit/CollectionTest.php index 18044fd..359775f 100644 --- a/tests/unit/CollectionTest.php +++ b/tests/unit/CollectionTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Subtext\Persistables\Collection; +use Subtext\Persistables\Tests\Unit\Fixtures\ComplexAggregate; use Subtext\Persistables\Tests\Unit\Fixtures\SimpleEntity; use InvalidArgumentException; @@ -25,6 +26,6 @@ public function getEntityClass(): string $this->assertSame($expected, $unit->getFirst()); $this->expectException(InvalidArgumentException::class); - $unit->append($this->createMock(SimpleEntity::class)); + $unit->append($this->createMock(ComplexAggregate::class)); } } diff --git a/tests/unit/FactoryTest.php b/tests/unit/FactoryTest.php index 3d34d67..0080b53 100644 --- a/tests/unit/FactoryTest.php +++ b/tests/unit/FactoryTest.php @@ -3,6 +3,7 @@ namespace Subtext\Persistables\Tests\Unit; use ArrayIterator; +use Cassandra\Aggregate; use DateTime; use PDO; use PHPUnit\Framework\TestCase; @@ -20,6 +21,9 @@ use Subtext\Persistables\Databases\Sql; use Subtext\Persistables\Factory; use Subtext\Persistables\Modifications; +use Subtext\Persistables\Tests\Unit\Fixtures\ChildEntity; +use Subtext\Persistables\Tests\Unit\Fixtures\Children; +use Subtext\Persistables\Tests\Unit\Fixtures\ComplexAggregate; use Subtext\Persistables\Tests\Unit\Fixtures\SimpleEntity; use InvalidArgumentException; use Subtext\Persistables\Tests\Unit\Fixtures\WithEntityExplicit; @@ -153,6 +157,122 @@ public function testGetEntityByPrimaryKeyWithAggregate(): void $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 testGetEntityByPrimaryKeyWillThrowInvalidArgumentException(): void { $database = $this->createMock(Sql::class); @@ -610,6 +730,119 @@ public function testPersistWillUpdateOrInsertCollection(): void $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); 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/SimpleEntity.php b/tests/unit/Fixtures/SimpleEntity.php index 609df78..6a31aa2 100644 --- a/tests/unit/Fixtures/SimpleEntity.php +++ b/tests/unit/Fixtures/SimpleEntity.php @@ -37,6 +37,9 @@ public function setName(string $name): void public function jsonSerialize(): mixed { - return ['id' => $this->id, 'name' => $this->name]; + return [ + 'id' => $this->getId(), + 'name' => $this->getName(), + ]; } } From 01ba8d29a3ce71af18618a4eddb75544130c4f1b Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Thu, 4 Sep 2025 10:38:20 -0500 Subject: [PATCH 08/12] formalize the persistables container --- src/Container.php | 23 ++++++++++++++++ src/Factory.php | 18 +------------ tests/unit/ContainerTest.php | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 src/Container.php create mode 100644 tests/unit/ContainerTest.php 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 @@ + $descendant) { $collection->set($property, $descendant); } 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()); + } +} From 9ec64278372fb17756ed46c043b8d4fce07c4a7c Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Thu, 4 Sep 2025 12:30:18 -0500 Subject: [PATCH 09/12] add an owner to entity relationship --- src/Factory.php | 23 ++++- tests/unit/FactoryTest.php | 115 +++++++++++++++++++++++++ tests/unit/Fixtures/OwnerAggregate.php | 64 ++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Fixtures/OwnerAggregate.php diff --git a/src/Factory.php b/src/Factory.php index 8cf1c8e..13cdf37 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -7,6 +7,7 @@ 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; @@ -388,6 +389,23 @@ protected function getPrimaryKey(Persistable $object): string 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); @@ -419,7 +437,10 @@ protected function resolvePrimaryKeyName(Meta $meta): 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(); } diff --git a/tests/unit/FactoryTest.php b/tests/unit/FactoryTest.php index 0080b53..3c0521d 100644 --- a/tests/unit/FactoryTest.php +++ b/tests/unit/FactoryTest.php @@ -24,6 +24,7 @@ use Subtext\Persistables\Tests\Unit\Fixtures\ChildEntity; use Subtext\Persistables\Tests\Unit\Fixtures\Children; use Subtext\Persistables\Tests\Unit\Fixtures\ComplexAggregate; +use Subtext\Persistables\Tests\Unit\Fixtures\OwnerAggregate; use Subtext\Persistables\Tests\Unit\Fixtures\SimpleEntity; use InvalidArgumentException; use Subtext\Persistables\Tests\Unit\Fixtures\WithEntityExplicit; @@ -273,6 +274,108 @@ public function testGetEntityByPrimaryKeyWithComplexAggregate(): void $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); @@ -383,6 +486,12 @@ public function testGetEntityCollectionWillSetByEntityPrimaryKey(): void $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)) @@ -401,6 +510,12 @@ public function testPersistWillInsert(): void $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()) diff --git a/tests/unit/Fixtures/OwnerAggregate.php b/tests/unit/Fixtures/OwnerAggregate.php new file mode 100644 index 0000000..d3cb8bf --- /dev/null +++ b/tests/unit/Fixtures/OwnerAggregate.php @@ -0,0 +1,64 @@ +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(), + ]; + } +} From 98e8f59c9a094b4f04183dc07cdbdabe36e1cb08 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Thu, 4 Sep 2025 18:48:16 -0500 Subject: [PATCH 10/12] add automated unit testing in github --- .github/workflows/tests-unit.yml | 61 ++++++++++++++++++++++++++++++++ .gitignore | 3 +- README.md | 1 + phpunit.xml | 9 +++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests-unit.yml diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml new file mode 100644 index 0000000..75cf986 --- /dev/null +++ b/.github/workflows/tests-unit.yml @@ -0,0 +1,61 @@ +name: Run Unit Tests + +on: + pull_request: + types: + - "opened" + - "reopened" + - "synchronize" + branches: + - master + +jobs: + phpunit_test: + name: "Unit Test" + runs-on: ubuntu-22.04 + 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 c7e9521..bec65f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Subtext\Persistables +![Run Unit Tests](https://github.com/subtext/persistables/actions/workflows/tests-unit.yml/badge.svg) A lightweight PHP library designed to abstract and unify the persistence of domain objects across SQL databases. Inspired by the principles of ORMs, but diff --git a/phpunit.xml b/phpunit.xml index 537c108..bf54bd4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,4 +17,13 @@ src + + + + + + + + + From b9df7b8ec274dee5afe83e5db63d6c1713baf0f9 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Sat, 6 Sep 2025 11:31:57 -0500 Subject: [PATCH 11/12] refactor the connection --- src/Databases/Connection.php | 57 +++++++++++--- src/Databases/Connections/Auth.php | 15 ---- src/Databases/Connections/MySql.php | 75 ------------------- tests/unit/Databases/ConnectionTest.php | 53 +++++++++++++ .../unit/Databases/Connections/MySqlTest.php | 34 --------- tests/unit/Databases/SqlTest.php | 6 ++ 6 files changed, 105 insertions(+), 135 deletions(-) delete mode 100644 src/Databases/Connections/Auth.php delete mode 100644 src/Databases/Connections/MySql.php create mode 100644 tests/unit/Databases/ConnectionTest.php delete mode 100644 tests/unit/Databases/Connections/MySqlTest.php diff --git a/src/Databases/Connection.php b/src/Databases/Connection.php index 1033750..083673e 100644 --- a/src/Databases/Connection.php +++ b/src/Databases/Connection.php @@ -2,23 +2,58 @@ namespace Subtext\Persistables\Databases; +use Closure; use PDO; -use TypeError; +use Subtext\Persistables\Databases\SqlGenerators\MySqlGenerator; -interface Connection +class Connection { /** - * Singleton creator - * - * @return self + * A container for configuring the PDO object. Pass in all of the parameters + * and use them as arguments for a callable + * @param string $database The database name. + * @param string $hostname The database host url. + * @param string $username The database username. + * @param string $password The database password. + * @param string $driver A compatible PDO driver string name. + * @param string $charset The character set to pass to the PDO object. + * @param Closure $fn A function which accepts each of the previous + * arguments and returns a PDO object. */ - public static function getInstance(): self; + public function __construct( + private readonly string $database, + private readonly string $hostname, + private readonly string $username, + private readonly string $password, + private readonly string $driver, + private readonly string $charset, + private readonly Closure $fn + ) {} + + public function getPdo(): ?PDO + { + $pdo = null; + if ($this->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/Auth.php b/src/Databases/Connections/Auth.php deleted file mode 100644 index a95cff3..0000000 --- a/src/Databases/Connections/Auth.php +++ /dev/null @@ -1,15 +0,0 @@ -pdo = $pdo; - $this->generator = MySqlGenerator::getInstance(); - } - - /** - * Database credentials must be applied as environment variables. - * - * @param Auth|null $auth - * @param bool $new - * @return self - */ - public static function getInstance(?Auth $auth = null, bool $new = false): self - { - try { - if (self::$instance === null || $new) { - if ($auth === null) { - $name = getenv('DB_NAME'); - $host = getenv('DB_HOST'); - $user = getenv('DB_USER'); - $pass = getenv('DB_PASS'); - $char = 'utf8mb4'; - } else { - $name = $auth->database; - $host = $auth->hostname; - $user = $auth->username; - $pass = $auth->password; - $char = $auth->charset; - } - self::$instance = new self(new PDO( - "mysql:dbname=$name;host=$host;charset=$char", - $user, - $pass, - )); - } - } catch (PDOException) { - self::$instance = new self(null); - } finally { - return self::$instance; - } - } - - public function getPdo(): PDO - { - if ($this->pdo === null) { - throw new RuntimeException( - 'MySql PDO is not initialized, check credentials.' - ); - } - return $this->pdo; - } - - public function getSqlGenerator(): SqlGenerator - { - return $this->generator; - } -} 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/Connections/MySqlTest.php b/tests/unit/Databases/Connections/MySqlTest.php deleted file mode 100644 index e5ce594..0000000 --- a/tests/unit/Databases/Connections/MySqlTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertInstanceOf(Connection::class, $unit); - $this->assertInstanceOf(PDO::class, $unit->getPdo()); - $this->assertInstanceOf(SqlGenerator::class, $unit->getSqlGenerator()); - - $actual = MySql::getInstance(); - $this->assertSame($unit, $actual); - } - - public function testWillThrowException(): void - { - $unit = MySql::getInstance(new Auth('', '', '', '', ''), true); - - $this->expectException(RuntimeException::class); - $unit->getPdo(); - } -} diff --git a/tests/unit/Databases/SqlTest.php b/tests/unit/Databases/SqlTest.php index ffbd3e4..5a776de 100644 --- a/tests/unit/Databases/SqlTest.php +++ b/tests/unit/Databases/SqlTest.php @@ -334,6 +334,9 @@ public function testGetIdForInsertWillReturnZeroIfSomethingGoesWrong(): void 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); @@ -366,6 +369,9 @@ public function testCanGetCountForUpdateQuery(): void 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); From 00596e0c6d805bba5b80edee3411bff00c831835 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Sat, 6 Sep 2025 11:58:21 -0500 Subject: [PATCH 12/12] apply coverage for xdebug --- .github/workflows/tests-unit.yml | 2 ++ debug.xml | 23 +++++++++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 26 insertions(+) create mode 100644 debug.xml diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 75cf986..2b10cb6 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -13,6 +13,8 @@ jobs: phpunit_test: name: "Unit Test" runs-on: ubuntu-22.04 + env: + XDEBUG_MODE: coverage steps: - name: Checkout uses: actions/checkout@v4 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