From 11dfd91abdbb27e820916a69632117238fc37ce1 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Fri, 19 Jan 2024 15:56:53 +0100 Subject: [PATCH 01/11] refactor: clearly prefix our test/sample classes prevents annoying suggestions in IDE when looking for your own "User", for example! --- .idea/codeception.xml | 12 ++ .idea/discord.xml | 7 + .idea/instarecord.iml | 2 + .idea/phpspec.xml | 13 ++ .idea/phpunit.xml | 10 ++ tests/Database/AutoColumnTest.php | 14 +- tests/Database/ColumnTest.php | 10 +- tests/Database/DataFormattingTest.php | 16 +-- tests/Database/QueryPaginatorTest.php | 16 +-- tests/Database/QueryTest.php | 16 +-- tests/Database/TableTest.php | 5 +- tests/Database/TimezoneTest.php | 12 +- tests/ModelTest.php | 134 +++++++++--------- tests/Models/ReadOnlyModelTest.php | 14 +- ...{AutoColumnTest.php => TestAutoColumn.php} | 2 +- ...tBadAuto.php => TestAutoColumnBadAuto.php} | 2 +- .../{DefaultsTest.php => TestDefaults.php} | 4 +- ...Type.php => TestDummySerializableType.php} | 2 +- .../Samples/{EnumSample.php => TestEnum.php} | 2 +- .../{NullableTest.php => TestNullable.php} | 2 +- ...{ReadOnlyUser.php => TestReadOnlyUser.php} | 2 +- tests/Samples/{User.php => TestUser.php} | 12 +- .../{UserAutoTest.php => TestUserAuto.php} | 2 +- tests/Samples/TestUserWithSerialized.php | 2 +- 24 files changed, 183 insertions(+), 130 deletions(-) create mode 100644 .idea/codeception.xml create mode 100644 .idea/discord.xml create mode 100644 .idea/phpspec.xml create mode 100644 .idea/phpunit.xml rename tests/Samples/{AutoColumnTest.php => TestAutoColumn.php} (84%) rename tests/Samples/{AutoColumnTestBadAuto.php => TestAutoColumnBadAuto.php} (75%) rename tests/Samples/{DefaultsTest.php => TestDefaults.php} (74%) rename tests/Samples/{DummySerializableType.php => TestDummySerializableType.php} (89%) rename tests/Samples/{EnumSample.php => TestEnum.php} (83%) rename tests/Samples/{NullableTest.php => TestNullable.php} (86%) rename tests/Samples/{ReadOnlyUser.php => TestReadOnlyUser.php} (85%) rename tests/Samples/{User.php => TestUser.php} (76%) rename tests/Samples/{UserAutoTest.php => TestUserAuto.php} (92%) diff --git a/.idea/codeception.xml b/.idea/codeception.xml new file mode 100644 index 0000000..330f2dd --- /dev/null +++ b/.idea/codeception.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..d8e9561 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/instarecord.iml b/.idea/instarecord.iml index f890830..a465ef3 100644 --- a/.idea/instarecord.iml +++ b/.idea/instarecord.iml @@ -3,7 +3,9 @@ + + diff --git a/.idea/phpspec.xml b/.idea/phpspec.xml new file mode 100644 index 0000000..ec7e1d4 --- /dev/null +++ b/.idea/phpspec.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/phpunit.xml b/.idea/phpunit.xml new file mode 100644 index 0000000..4f8104c --- /dev/null +++ b/.idea/phpunit.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/tests/Database/AutoColumnTest.php b/tests/Database/AutoColumnTest.php index e7f022c..199261f 100644 --- a/tests/Database/AutoColumnTest.php +++ b/tests/Database/AutoColumnTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; use SoftwarePunt\Instarecord\Database\Table; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\UserAutoTest; +use SoftwarePunt\Instarecord\Tests\Samples\TestUserAuto; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class AutoColumnTest extends TestCase @@ -15,7 +15,7 @@ class AutoColumnTest extends TestCase */ public function testAutoModeDetermination() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\AutoColumnTest'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestAutoColumn'); $this->assertEquals("created", $table->getColumnByPropertyName("createdAt")->getAutoMode()); $this->assertEquals("modified", $table->getColumnByPropertyName("modifiedAt")->getAutoMode()); @@ -27,7 +27,7 @@ public function testAutoModeDetermination() */ public function testAutoModeRequiresCompatibleType() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\AutoColumnTestBadAuto'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestAutoColumnBadAuto'); $this->assertNull($table->getColumnByPropertyName("createdAt")->getAutoMode()); } @@ -38,7 +38,7 @@ public function testAutoCreateOnNewRecord() { Instarecord::config(new TestDatabaseConfig()); - $uat = new UserAutoTest(); + $uat = new TestUserAuto(); try { $uat->userName = "Newly Created"; @@ -57,7 +57,7 @@ public function testAutoCreateOnNewRecordLeavesExplicitValue() { Instarecord::config(new TestDatabaseConfig()); - $uat = new UserAutoTest(); + $uat = new TestUserAuto(); try { $testDt = new \DateTime("1970-01-02 03:04:05"); @@ -79,7 +79,7 @@ public function testAutoModifiedOnNewRecord() { Instarecord::config(new TestDatabaseConfig()); - $uat = new UserAutoTest(); + $uat = new TestUserAuto(); try { $uat->userName = "Newly Created"; @@ -98,7 +98,7 @@ public function testAutoModifiedOnUpdatedRecord() { Instarecord::config(new TestDatabaseConfig()); - $uat = new UserAutoTest(); + $uat = new TestUserAuto(); try { $uat->userName = "Newly Created"; diff --git a/tests/Database/ColumnTest.php b/tests/Database/ColumnTest.php index e9cf5ad..d6b3ac2 100644 --- a/tests/Database/ColumnTest.php +++ b/tests/Database/ColumnTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use SoftwarePunt\Instarecord\Database\Column; use SoftwarePunt\Instarecord\Database\Table; -use Softwarepunt\Instarecord\Tests\Samples\EnumSample; +use Softwarepunt\Instarecord\Tests\Samples\TestEnum; class ColumnTest extends TestCase { @@ -22,7 +22,7 @@ public function testDetermineDefaultColumnName() public function testGeneratesDefaultColumnNames() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\User'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestUser'); $column = new Column($table, 'myPropName', null); $this->assertEquals('my_prop_name', $column->getColumnName(), "If no custom @columnn annotation is set, default column conventions should be assumed"); @@ -30,7 +30,7 @@ public function testGeneratesDefaultColumnNames() public function testUnderstandsNullables() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\NullableTest'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestNullable'); $this->assertFalse($table->getColumnByPropertyName('stringNonNullable')->getIsNullable()); $this->assertTrue($table->getColumnByPropertyName('stringNullableThroughType')->getIsNullable()); @@ -38,11 +38,11 @@ public function testUnderstandsNullables() public function testReadsDefaultValues() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\DefaultsTest'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestDefaults'); $this->assertEquals("hello1", $table->getColumnByPropertyName('strNullableWithDefault')->getDefaultValue()); $this->assertEquals("hello2", $table->getColumnByPropertyName('strNonNullableWithDefault')->getDefaultValue()); $this->assertEquals(null, $table->getColumnByPropertyName('strDefaultNullValue')->getDefaultValue()); - $this->assertEquals(EnumSample::Three, $table->getColumnByPropertyName('enumWithDefault')->getDefaultValue()); + $this->assertEquals(TestEnum::Three, $table->getColumnByPropertyName('enumWithDefault')->getDefaultValue()); } } \ No newline at end of file diff --git a/tests/Database/DataFormattingTest.php b/tests/Database/DataFormattingTest.php index badabd9..e347a42 100644 --- a/tests/Database/DataFormattingTest.php +++ b/tests/Database/DataFormattingTest.php @@ -7,14 +7,14 @@ use SoftwarePunt\Instarecord\Database\Column; use SoftwarePunt\Instarecord\Database\Table; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\DummySerializableType; -use Softwarepunt\Instarecord\Tests\Samples\EnumSample; +use SoftwarePunt\Instarecord\Tests\Samples\TestDummySerializableType; +use Softwarepunt\Instarecord\Tests\Samples\TestEnum; class DataFormattingTest extends TestCase { private function _createTestColumn(array $opts) { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\User'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestUser'); $column = new Column($table, 'testColumn', null); if (!empty($opts['var'])) { @@ -248,11 +248,11 @@ public function testIDatabaseSerializable() $column = $this->_createTestColumn([ 'var' => Column::TYPE_SERIALIZED_OBJECT, 'nullable' => true, - 'reftype' => new DummySerializableType() + 'reftype' => new TestDummySerializableType() ]); $this->assertSame(null, $column->parseDatabaseValue(null)); - $this->assertEquals($obj = new DummySerializableType("test 123"), $column->parseDatabaseValue("test 123")); + $this->assertEquals($obj = new TestDummySerializableType("test 123"), $column->parseDatabaseValue("test 123")); } public function testEnum() @@ -260,11 +260,11 @@ public function testEnum() $column = $this->_createTestColumn([ 'var' => Column::TYPE_ENUM, 'nullable' => true, - 'enumtype' => EnumSample::class + 'enumtype' => TestEnum::class ]); $this->assertSame(null, $column->parseDatabaseValue(null)); - $this->assertSame("three", $column->formatDatabaseValue(EnumSample::Three)); - $this->assertEquals(EnumSample::Two, $column->parseDatabaseValue("two")); + $this->assertSame("three", $column->formatDatabaseValue(TestEnum::Three)); + $this->assertEquals(TestEnum::Two, $column->parseDatabaseValue("two")); } } diff --git a/tests/Database/QueryPaginatorTest.php b/tests/Database/QueryPaginatorTest.php index d8bbcb1..869fe31 100644 --- a/tests/Database/QueryPaginatorTest.php +++ b/tests/Database/QueryPaginatorTest.php @@ -6,7 +6,7 @@ use SoftwarePunt\Instarecord\Database\Connection; use SoftwarePunt\Instarecord\Database\Query; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\User; +use SoftwarePunt\Instarecord\Tests\Samples\TestUser; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class QueryPaginatorTest extends TestCase @@ -18,13 +18,13 @@ public static function setUpBeforeClass(): void Instarecord::config($config); - $userQuery = User::query() + $userQuery = TestUser::query() ->delete(); } public static function tearDownAfterClass(): void { - $userQuery = User::query() + $userQuery = TestUser::query() ->delete(); } @@ -43,7 +43,7 @@ public function testPaginateConstructor() public function testPaginateCalculation() { // Create dummy user that will be excluded from our query - $user = new User(); + $user = new TestUser(); $user->userName = "dummy-for-paginator"; $user->save(); @@ -51,7 +51,7 @@ public function testPaginateCalculation() $firstUser = null; for ($i = 0; $i < 10; $i++) { - $user = new User(); + $user = new TestUser(); $user->userName = "paginate-{$i}"; $user->save(); @@ -61,10 +61,10 @@ public function testPaginateCalculation() } // Sanity check - $this->assertEquals(11, User::query()->count()->querySingleValue()); + $this->assertEquals(11, TestUser::query()->count()->querySingleValue()); // Create paginator - $paginator = User::query() + $paginator = TestUser::query() ->where('user_name LIKE "paginate-%"') ->orderBy('user_name DESC') ->paginate(); @@ -101,7 +101,7 @@ public function testPaginateCalculation() public function testPaginateCalculationWithZeroResults() { - $paginator = User::query() + $paginator = TestUser::query() ->where('id < 0') ->paginate(); diff --git a/tests/Database/QueryTest.php b/tests/Database/QueryTest.php index 0e9f7f8..79db20b 100644 --- a/tests/Database/QueryTest.php +++ b/tests/Database/QueryTest.php @@ -7,7 +7,7 @@ use SoftwarePunt\Instarecord\Database\Connection; use SoftwarePunt\Instarecord\Database\Query; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\User; +use SoftwarePunt\Instarecord\Tests\Samples\TestUser; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class QueryTest extends TestCase @@ -434,11 +434,11 @@ public function testWhereWithBoundArray() $query = new Query(Instarecord::connection()); - $testUserA = new User(); + $testUserA = new TestUser(); $testUserA->userName = 'ArrayGuyOne'; $testUserA->save(); - $testUserB = new User(); + $testUserB = new TestUser(); $testUserB->userName = 'ArrayGuyTwo'; $testUserB->save(); @@ -616,7 +616,7 @@ public function testQuerySingleValue() Instarecord::config($config); - $testUser = new User(); + $testUser = new TestUser(); $testUser->userName = 'HenkTheSingleGuy'; $testUser->save(); @@ -639,11 +639,11 @@ public function testQuerySingleValueArray() Instarecord::config($config); - $testUserA = new User(); + $testUserA = new TestUser(); $testUserA->userName = 'ArrItemOneSVA'; $testUserA->save(); - $testUserB = new User(); + $testUserB = new TestUser(); $testUserB->userName = 'ArrItemTwoSVA'; $testUserB->save(); @@ -668,11 +668,11 @@ public function testQueryKeyValueArray() Instarecord::config($config); - $testUserA = new User(); + $testUserA = new TestUser(); $testUserA->userName = 'ArrItemOneKVA'; $testUserA->save(); - $testUserB = new User(); + $testUserB = new TestUser(); $testUserB->userName = 'ArrItemTwoKVA'; $testUserB->save(); diff --git a/tests/Database/TableTest.php b/tests/Database/TableTest.php index 320cc67..0572d7f 100644 --- a/tests/Database/TableTest.php +++ b/tests/Database/TableTest.php @@ -10,6 +10,7 @@ class TableTest extends TestCase public function testDefaultTableNameGeneration() { $this->assertEquals("users", Table::getDefaultTableName("User")); + $this->assertEquals("test_users", Table::getDefaultTableName("TestUser")); $this->assertEquals("my_table_classes", Table::getDefaultTableName("MyTableClass")); $this->assertEquals("my_table_classes", Table::getDefaultTableName("myTableClass")); $this->assertEquals("my_table_classes", Table::getDefaultTableName("My\\Qualified\\Namespaced\\MyTableClass")); @@ -33,7 +34,7 @@ public function testConstructorErrorsOnNonModelClassName() public function testExtractsIndexedColumnList() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\User'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestUser'); $columns = $table->getColumns(); $this->assertNotEmpty($columns, 'Expected a non-empty columns list'); @@ -46,7 +47,7 @@ public function testExtractsIndexedColumnList() */ public function testMemoryCachesColumnList() { - $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\User'); + $table = new Table('SoftwarePunt\\Instarecord\\Tests\\Samples\\TestUser'); $columns = $table->getColumns(); $columns2 = $table->getColumns(); diff --git a/tests/Database/TimezoneTest.php b/tests/Database/TimezoneTest.php index 553c774..8afa184 100644 --- a/tests/Database/TimezoneTest.php +++ b/tests/Database/TimezoneTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\User; +use SoftwarePunt\Instarecord\Tests\Samples\TestUser; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class TimezoneTest extends TestCase @@ -22,12 +22,12 @@ public function testTimezoneWriteConsistency() $jan1st = new \DateTime("2023-01-01 00:00:00"); - $user = new User(); + $user = new TestUser(); $user->userName = "TimeZoneTest"; $user->joinDate = $jan1st; $user->save(); - $userRefetch = User::fetch($user->id); + $userRefetch = TestUser::fetch($user->id); $this->assertSame( "2023-01-01 00:00:00", @@ -49,7 +49,7 @@ public function testTimezoneQueryConsistency() $jan1st = new \DateTime("2023-01-01 00:00:00"); - $query = User::query()->where('join_date = ?', $jan1st); + $query = TestUser::query()->where('join_date = ?', $jan1st); $queryString = $query->createStatementText(); $queryParams = $query->getBoundParametersForGeneratedStatement(); @@ -73,7 +73,7 @@ public function testTimezoneQueryInconsistency_Up() $jan1st = new \DateTime("2023-01-01 00:00:00"); - $query = User::query()->where('join_date = ?', $jan1st); + $query = TestUser::query()->where('join_date = ?', $jan1st); $queryString = $query->createStatementText(); $queryParams = $query->getBoundParametersForGeneratedStatement(); @@ -97,7 +97,7 @@ public function testTimezoneQueryInconsistency_Down() $jan1st = new \DateTime("2023-01-01 00:00:00"); - $query = User::query()->where('join_date = ?', $jan1st); + $query = TestUser::query()->where('join_date = ?', $jan1st); $queryString = $query->createStatementText(); $queryParams = $query->getBoundParametersForGeneratedStatement(); diff --git a/tests/ModelTest.php b/tests/ModelTest.php index c6aa1bc..658d21c 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -5,17 +5,17 @@ use PHPUnit\Framework\TestCase; use SoftwarePunt\Instarecord\Database\Column; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\DummySerializableType; -use Softwarepunt\Instarecord\Tests\Samples\EnumSample; +use SoftwarePunt\Instarecord\Tests\Samples\TestDummySerializableType; +use Softwarepunt\Instarecord\Tests\Samples\TestEnum; use SoftwarePunt\Instarecord\Tests\Samples\TestUserWithSerialized; -use SoftwarePunt\Instarecord\Tests\Samples\User; +use SoftwarePunt\Instarecord\Tests\Samples\TestUser; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class ModelTest extends TestCase { public function testConstructWithDefaults() { - $user = new User([ + $user = new TestUser([ 'id' => 123, 'userName' => 'Henk' ]); @@ -31,7 +31,7 @@ public function testGetProperties() 'userName' => 'Henk' ]; - $user = new User($values); + $user = new TestUser($values); $this->assertEquals($values['id'], $user->getPropertyValues()['id']); $this->assertEquals($values['userName'], $user->getPropertyValues()['userName']); @@ -44,7 +44,7 @@ public function testGetSetDirtyProperties() 'userName' => 'Henk' ]; - $user = new User($values); + $user = new TestUser($values); // Initial state; should be clean $this->assertEmpty($user->getDirtyProperties()); @@ -76,7 +76,7 @@ public function testGetDirtyColumns() 'user_name' => 'Henk' ]; - $user = new User($values); + $user = new TestUser($values); $this->assertEmpty($user->getDirtyColumns(), 'Dirty columns should initially be empty'); @@ -92,7 +92,7 @@ public function testMarkAllDirty() 'userName' => 'Henk' ]; - $user = new User($values); + $user = new TestUser($values); $this->assertEmpty($user->getDirtyProperties()); @@ -111,7 +111,7 @@ public function testMarkAllClean() 'userName' => 'Henk' ]; - $user = new User($values); + $user = new TestUser($values); $user->markAllPropertiesDirty(); @@ -124,7 +124,7 @@ public function testMarkAllClean() public function testGetPropertyNames() { - $sampleUserModel = new User(); + $sampleUserModel = new TestUser(); $propertyList = $sampleUserModel->getPropertyNames(); $this->assertNotEmpty($propertyList, 'A property list should not be empty'); @@ -135,7 +135,7 @@ public function testGetPropertyNames() public function testGetColumnNames() { - $sampleUserModel = new User(); + $sampleUserModel = new TestUser(); $propertyList = $sampleUserModel->getColumnNames(); $this->assertNotEmpty($propertyList, 'A property list should not be empty'); @@ -146,27 +146,27 @@ public function testGetColumnNames() public function testGetColumnNameForPropertyName() { - $sampleUserModel = new User(); + $sampleUserModel = new TestUser(); $this->assertEquals('id', $sampleUserModel->getColumnNameForPropertyName('id')); $this->assertEquals('user_name', $sampleUserModel->getColumnNameForPropertyName('userName')); } public function testGetPropertyNameForColumnName() { - $sampleUserModel = new User(); + $sampleUserModel = new TestUser(); $this->assertEquals('id', $sampleUserModel->getPropertyNameForColumnName('id')); $this->assertEquals('userName', $sampleUserModel->getPropertyNameForColumnName('user_name')); } public function testGetTableName() { - $sampleUserModel = new User(); + $sampleUserModel = new TestUser(); $this->assertEquals('users', $sampleUserModel->getTableName()); } public function testSetColumnValues() { - $user = new User(); + $user = new TestUser(); $user->setColumnValues([ 'id' => 5, 'user_name' => 'Bob' @@ -179,7 +179,7 @@ public function testSetColumnValues() public function testGetAndSetColumnValues() { - $user = new User(); + $user = new TestUser(); $testSet = [ 'id' => 5, @@ -196,7 +196,7 @@ public function testGetAndSetColumnValues() public function testGetPropertyValuesWithColumnNames() { - $user = new User(); + $user = new TestUser(); $testDate = new \DateTime(); $testDate->setDate(1978, 1, 2); @@ -224,7 +224,7 @@ public function testCreateSimple() { Instarecord::config(new TestDatabaseConfig()); - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "my-test-user-one"; $this->assertTrue($newUser->create(), 'Creating a new record should return TRUE'); @@ -240,14 +240,14 @@ public function testCreateWithoutAutoIncrement() Instarecord::config(new TestDatabaseConfig()); // Determine what the next auto increment number would be (max+1) - $nextUserId = intval(User::query() + $nextUserId = intval(TestUser::query() ->select('id') ->orderBy('id DESC') ->limit(1) ->querySingleValue()) + 1; // Create user without auto increment - $newUser = new User(); + $newUser = new TestUser(); $newUser->id = $nextUserId; $newUser->setUseAutoIncrement(false); $newUser->userName = "non-auto-incremented"; @@ -268,7 +268,7 @@ public function testCreateViaSave() { Instarecord::config(new TestDatabaseConfig()); - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "my-test-user-two"; $this->assertTrue($newUser->save(), 'Creating a new record should return TRUE (via save)'); @@ -281,22 +281,22 @@ public function testReload() Instarecord::config(new TestDatabaseConfig()); // Create new user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "reload-user"; - $newUser->enumValue = EnumSample::One; + $newUser->enumValue = TestEnum::One; $this->assertTrue($newUser->save(), "New user save should succeed"); $this->assertNotEmpty($newUser->id, "New user save should set PK value"); // Update user by query - User::query() + TestUser::query() ->update() - ->set('`enum_value` = ?', EnumSample::Two->value) + ->set('`enum_value` = ?', TestEnum::Two->value) ->where('id = ?', $newUser->id) ->execute(); // Reload user $this->assertTrue($newUser->reload(), "Reload should succeed"); - $this->assertEquals(EnumSample::Two, $newUser->enumValue, "Reloaded data should be applied"); + $this->assertEquals(TestEnum::Two, $newUser->enumValue, "Reloaded data should be applied"); // Delete & reload user $newUser->delete(); @@ -312,7 +312,7 @@ public function testUpdateCreatedRecordViaSave() Instarecord::config(new TestDatabaseConfig()); // 1. Insert user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "my-test-user-three"; $newUser->save(); @@ -338,17 +338,17 @@ public function testUpsert() // NB: "userName" has a unique index // 1. Upsert initial - $user1 = new User(); + $user1 = new TestUser(); $user1->userName = "mr-upsert"; - $user1->enumValue = EnumSample::One; + $user1->enumValue = TestEnum::One; $this->assertTrue($user1->upsert(), "upsert() should succeed on create"); $this->assertNotNull($user1->id, "upsert() should cause auto incremented PK value to be set on create"); // 2. Upsert update - $user2 = new User(); + $user2 = new TestUser(); $user2->userName = "mr-upsert"; - $user2->enumValue = EnumSample::Two; + $user2->enumValue = TestEnum::Two; $user2->upsert(); $this->assertTrue($user2->upsert(), "upsert() should succeed on update"); @@ -356,13 +356,13 @@ public function testUpsert() // 3. Refetch to ensure data was updated /** - * @var $userRefetched User|null + * @var $userRefetched TestUser|null */ - $userRefetched = User::query() + $userRefetched = TestUser::query() ->where('user_name = ?', "mr-upsert") ->querySingleModel(); - $this->assertSame($userRefetched->enumValue, $user2->enumValue, "User record should be updated in database after upsert()"); + $this->assertSame($userRefetched->enumValue, $user2->enumValue, "TestUser record should be updated in database after upsert()"); } /** @@ -373,7 +373,7 @@ public function testDelete() Instarecord::config(new TestDatabaseConfig()); // 1. Insert user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "will-be-deleted"; $newUser->save(); @@ -381,7 +381,7 @@ public function testDelete() $this->assertTrue($newUser->delete(), 'Delete should return true'); // 3. Insert the user again, noting that no "duplicate key" exceptions are raised - $newUser2 = new User(); + $newUser2 = new TestUser(); $newUser2->userName = "will-be-deleted"; $newUser2->save(); } @@ -391,12 +391,12 @@ public function testFetch() Instarecord::config(new TestDatabaseConfig()); // 1. Insert user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "imma-be-fetched-please"; $newUser->save(); // 2. Fetch user - $fetchUser = User::fetch($newUser->id); + $fetchUser = TestUser::fetch($newUser->id); $this->assertEquals($newUser->id, $fetchUser->id, 'Fetch should return a single user object based on the primary key'); $this->assertEquals('imma-be-fetched-please', $fetchUser->userName, 'Columns should be translated: userName should be filled with user_name value'); @@ -407,19 +407,19 @@ public function testFetchAll() Instarecord::config(new TestDatabaseConfig()); // 1. Insert user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "imma-be-listfetched-please"; $newUser->save(); // 2. Fetch user list - $fetchUserList = User::all(); + $fetchUserList = TestUser::all(); $this->assertNotEmpty($fetchUserList, 'Expected nonempty user list'); $containsOurItem = false; foreach ($fetchUserList as $fetchUserListItem) { - $this->assertInstanceOf("SoftwarePunt\\Instarecord\\Tests\\Samples\\User", $fetchUserListItem, 'Expected a list of user models'); + $this->assertInstanceOf("SoftwarePunt\\Instarecord\\Tests\\Samples\\TestUser", $fetchUserListItem, 'Expected a list of user models'); if ($fetchUserListItem->id == $newUser->id) { $containsOurItem = true; @@ -431,22 +431,22 @@ public function testFetchAll() public function testModelQueryUsesTableAndSelectAsDefaults() { - $allUsersViaQuery = User::query()->queryAllModels(); - $allUsersViaAll = User::all(); + $allUsersViaQuery = TestUser::query()->queryAllModels(); + $allUsersViaAll = TestUser::all(); $this->assertEquals($allUsersViaQuery, $allUsersViaAll); } public function testModelQueryAllWithIndexedWithPrimaryKey() { - $allUsersViaQuery = User::query()->queryAllModelsIndexed(); + $allUsersViaQuery = TestUser::query()->queryAllModelsIndexed(); // ------------------------------------------------------------------------------------------------------------- /** - * @var $allUsersViaAll User[] + * @var $allUsersViaAll TestUser[] */ - $allUsersViaAll = User::all(); + $allUsersViaAll = TestUser::all(); $userIds = []; @@ -472,14 +472,14 @@ public function testModelQueryAllWithIndexedWithPrimaryKey() public function testModelQueryAllWithIndexedWithCustomKey() { - $allUsersViaQuery = User::query()->queryAllModelsIndexed("userName"); + $allUsersViaQuery = TestUser::query()->queryAllModelsIndexed("userName"); // ------------------------------------------------------------------------------------------------------------- /** - * @var $allUsersViaAll User[] + * @var $allUsersViaAll TestUser[] */ - $allUsersViaAll = User::all(); + $allUsersViaAll = TestUser::all(); $userNames = []; @@ -508,19 +508,19 @@ public function testModelInsertsFormattedValuesAndParsesIncomingValues() Instarecord::config(new TestDatabaseConfig()); // Insert user with a formatted DateTime as their name, because why not - $newUser = new User(); + $newUser = new TestUser(); $testFormatStr = '1970-11-12 01:03:04'; $newUser->joinDate = new \DateTime($testFormatStr); - $newUser->enumValue = EnumSample::Two; + $newUser->enumValue = TestEnum::Two; $newUser->save(); // The fact no errors have occurred is a good first step: it means we inserted valid data. // Now re-fetch into a new model, and ensure that we get a nice datetime object parsed from the db. - $refetchedUser = User::fetch($newUser->id); + $refetchedUser = TestUser::fetch($newUser->id); $this->assertInstanceOf('\DateTime', $refetchedUser->joinDate, 'Database DateTime value should have been parsed into a DateTime object'); $this->assertEquals($testFormatStr, $refetchedUser->joinDate->format(Column::DATE_TIME_FORMAT), 'Database DateTime value should have been parsed correctly'); - $this->assertEquals(EnumSample::Two, $refetchedUser->enumValue, 'Database enum value should have been parsed correctly'); + $this->assertEquals(TestEnum::Two, $refetchedUser->enumValue, 'Database enum value should have been parsed correctly'); } public function testModelUpdatesFormattedValues() @@ -528,7 +528,7 @@ public function testModelUpdatesFormattedValues() Instarecord::config(new TestDatabaseConfig()); // Create initial user - $newUser = new User(); + $newUser = new TestUser(); $newUser->userName = "testModelUpdatesFormattedValues"; $newUser->save(); @@ -539,7 +539,7 @@ public function testModelUpdatesFormattedValues() // The fact no errors have occurred is a good first step: it means we updated the record with valid data. // Now re-fetch into a new model, and ensure that we get a nice datetime object parsed from the db. - $refetchedUser = User::fetch($newUser->id); + $refetchedUser = TestUser::fetch($newUser->id); $this->assertInstanceOf('\DateTime', $refetchedUser->joinDate, 'Database DateTime value should have been parsed into a DateTime object'); $this->assertEquals($testFormatStr, $refetchedUser->joinDate->format(Column::DATE_TIME_FORMAT), 'Database DateTime value should have been parsed correctly'); @@ -549,12 +549,12 @@ public function testFetchReturnsNullForNoResult() { Instarecord::config(new TestDatabaseConfig()); - $this->assertNull(User::fetch(123123123)); + $this->assertNull(TestUser::fetch(123123123)); } public function testDefaultValuesForNonNullableDataTypes() { - $newUser = new User(); + $newUser = new TestUser(); $this->assertEquals('', $newUser->userName, "Non-nullable strings should be set to EMPTY STRING by default"); $this->assertEquals(0, $newUser->id, "Non-nullable integers should be set to NULL by default"); @@ -563,7 +563,7 @@ public function testDefaultValuesForNonNullableDataTypes() public function testFetchPkVal() { - $newUser = new User(); + $newUser = new TestUser(); $this->assertEquals(0, $newUser->getPrimaryKeyValue()); @@ -574,14 +574,14 @@ public function testFetchPkVal() public function testFetchExisting() { - $existingJohn = new User(); + $existingJohn = new TestUser(); $existingJohn->userName = 'John Is Real'; $fetchResultOne = $existingJohn->fetchExisting(); $existingJohn->save(); - $matchingJohn = new User(); + $matchingJohn = new TestUser(); $matchingJohn->userName = 'John Is Real'; $fetchResultTwo = $matchingJohn->fetchExisting(); @@ -595,13 +595,13 @@ public function testTryBecomeExisting() $someDt = new \DateTime('now'); // Create the initial record, which we'll be trying to "become" - $existingJohn = new User(); + $existingJohn = new TestUser(); $existingJohn->userName = 'John Is The OG'; $existingJohn->joinDate = $someDt; $existingJohn->save(); // Try a failing scenario - $matchingJohn = new User(); + $matchingJohn = new TestUser(); $matchingJohn->userName = 'Mike Is The OG No Match Here'; $matchingJohn->joinDate = $someDt; @@ -609,7 +609,7 @@ public function testTryBecomeExisting() $this->assertEmpty($matchingJohn->id, 'tryBecomeExisting() should not set a PK ID if it returns false'); // Try a winning scenario - $matchingJohn = new User(); + $matchingJohn = new TestUser(); $matchingJohn->userName = 'John Is The OG'; $matchingJohn->joinDate = $someDt; @@ -623,19 +623,19 @@ public function testReadWriteSerializedType() $user = new TestUserWithSerialized(); $this->assertNull($user->userName, "By default, a nullable serialized type should have a NULL value"); - $user->userName = new DummySerializableType("Mr. Hands"); + $user->userName = new TestDummySerializableType("Mr. Hands"); $this->assertTrue($user->save(), "Saving a serializable object value should succeed"); - $user = User::fetch($user->id); + $user = TestUser::fetch($user->id); $this->assertSame("Mr. Hands", $user->userName, "Reading a serialized value as string should work"); $user = TestUserWithSerialized::fetch($user->id); - $this->assertEquals(new DummySerializableType("Mr. Hands"), $user->userName, "Reading a serialized object from database should work"); + $this->assertEquals(new TestDummySerializableType("Mr. Hands"), $user->userName, "Reading a serialized object from database should work"); } public function testTrySave() { - $user = new User(); + $user = new TestUser(); $exceptionThrown = false; try { diff --git a/tests/Models/ReadOnlyModelTest.php b/tests/Models/ReadOnlyModelTest.php index e8f8b54..a8dfee9 100644 --- a/tests/Models/ReadOnlyModelTest.php +++ b/tests/Models/ReadOnlyModelTest.php @@ -4,8 +4,8 @@ use PHPUnit\Framework\TestCase; use SoftwarePunt\Instarecord\Instarecord; -use SoftwarePunt\Instarecord\Tests\Samples\ReadOnlyUser; -use SoftwarePunt\Instarecord\Tests\Samples\User; +use SoftwarePunt\Instarecord\Tests\Samples\TestReadOnlyUser; +use SoftwarePunt\Instarecord\Tests\Samples\TestUser; use SoftwarePunt\Instarecord\Tests\Testing\TestDatabaseConfig; class ReadOnlyModelTest extends TestCase @@ -17,7 +17,7 @@ public function testCreateRejectedForReadOnlyModel() Instarecord::config(new TestDatabaseConfig()); - $rou = new ReadOnlyUser(); + $rou = new TestReadOnlyUser(); $rou->userName = "NewTest"; $rou->create(); } @@ -26,12 +26,12 @@ public function testUpdateRejectedForReadOnlyModel() { Instarecord::config(new TestDatabaseConfig()); - $normieUser = new User(); + $normieUser = new TestUser(); $normieUser->userName = "RejectMe"; $normieUser->save(); try { - $rou = ReadOnlyUser::fetch($normieUser->id); + $rou = TestReadOnlyUser::fetch($normieUser->id); $this->assertTrue($rou->update()); // no changes should still pass safely @@ -49,12 +49,12 @@ public function testDeleteRejectedForReadOnlyModel() { Instarecord::config(new TestDatabaseConfig()); - $normieUser = new User(); + $normieUser = new TestUser(); $normieUser->userName = "RejectMe"; $normieUser->save(); try { - $rou = ReadOnlyUser::fetch($normieUser->id); + $rou = TestReadOnlyUser::fetch($normieUser->id); $this->expectException("SoftwarePunt\Instarecord\Models\ModelAccessException"); $this->expectExceptionMessage("read only model"); diff --git a/tests/Samples/AutoColumnTest.php b/tests/Samples/TestAutoColumn.php similarity index 84% rename from tests/Samples/AutoColumnTest.php rename to tests/Samples/TestAutoColumn.php index ada65cc..43c197e 100644 --- a/tests/Samples/AutoColumnTest.php +++ b/tests/Samples/TestAutoColumn.php @@ -4,7 +4,7 @@ use SoftwarePunt\Instarecord\Model; -class AutoColumnTest extends Model +class TestAutoColumn extends Model { public int $id; public \DateTime $createdAt; diff --git a/tests/Samples/AutoColumnTestBadAuto.php b/tests/Samples/TestAutoColumnBadAuto.php similarity index 75% rename from tests/Samples/AutoColumnTestBadAuto.php rename to tests/Samples/TestAutoColumnBadAuto.php index fdbc2ed..172ce27 100644 --- a/tests/Samples/AutoColumnTestBadAuto.php +++ b/tests/Samples/TestAutoColumnBadAuto.php @@ -4,7 +4,7 @@ use SoftwarePunt\Instarecord\Model; -class AutoColumnTestBadAuto extends Model +class TestAutoColumnBadAuto extends Model { public string $createdAt; } \ No newline at end of file diff --git a/tests/Samples/DefaultsTest.php b/tests/Samples/TestDefaults.php similarity index 74% rename from tests/Samples/DefaultsTest.php rename to tests/Samples/TestDefaults.php index c10e403..d8c872b 100644 --- a/tests/Samples/DefaultsTest.php +++ b/tests/Samples/TestDefaults.php @@ -4,11 +4,11 @@ use SoftwarePunt\Instarecord\Model; -class DefaultsTest extends Model +class TestDefaults extends Model { public int $id; public ?string $strNullableWithDefault = "hello1"; public string $strNonNullableWithDefault = "hello2"; public ?string $strDefaultNullValue = null; - public EnumSample $enumWithDefault = EnumSample::Three; + public TestEnum $enumWithDefault = TestEnum::Three; } \ No newline at end of file diff --git a/tests/Samples/DummySerializableType.php b/tests/Samples/TestDummySerializableType.php similarity index 89% rename from tests/Samples/DummySerializableType.php rename to tests/Samples/TestDummySerializableType.php index 82e7401..86ca3b5 100644 --- a/tests/Samples/DummySerializableType.php +++ b/tests/Samples/TestDummySerializableType.php @@ -4,7 +4,7 @@ use SoftwarePunt\Instarecord\Serialization\IDatabaseSerializable; -class DummySerializableType implements IDatabaseSerializable +class TestDummySerializableType implements IDatabaseSerializable { private string $value = ""; diff --git a/tests/Samples/EnumSample.php b/tests/Samples/TestEnum.php similarity index 83% rename from tests/Samples/EnumSample.php rename to tests/Samples/TestEnum.php index 52bac1a..a8689a4 100644 --- a/tests/Samples/EnumSample.php +++ b/tests/Samples/TestEnum.php @@ -2,7 +2,7 @@ namespace Softwarepunt\Instarecord\Tests\Samples; -enum EnumSample : string +enum TestEnum : string { case One = 'one'; case Two = 'two'; diff --git a/tests/Samples/NullableTest.php b/tests/Samples/TestNullable.php similarity index 86% rename from tests/Samples/NullableTest.php rename to tests/Samples/TestNullable.php index 5695e7e..bc29590 100644 --- a/tests/Samples/NullableTest.php +++ b/tests/Samples/TestNullable.php @@ -4,7 +4,7 @@ use SoftwarePunt\Instarecord\Model; -class NullableTest extends Model +class TestNullable extends Model { public int $id; public string $stringNonNullable; diff --git a/tests/Samples/ReadOnlyUser.php b/tests/Samples/TestReadOnlyUser.php similarity index 85% rename from tests/Samples/ReadOnlyUser.php rename to tests/Samples/TestReadOnlyUser.php index 91d5105..9af4e63 100644 --- a/tests/Samples/ReadOnlyUser.php +++ b/tests/Samples/TestReadOnlyUser.php @@ -8,7 +8,7 @@ /** * @table users */ -class ReadOnlyUser extends Model implements IReadOnlyModel +class TestReadOnlyUser extends Model implements IReadOnlyModel { private int $secretNotWritable; public int $id; diff --git a/tests/Samples/User.php b/tests/Samples/TestUser.php similarity index 76% rename from tests/Samples/User.php rename to tests/Samples/TestUser.php index 958a669..3057a5a 100644 --- a/tests/Samples/User.php +++ b/tests/Samples/TestUser.php @@ -4,7 +4,7 @@ use SoftwarePunt\Instarecord\Model; -class User extends Model +class TestUser extends Model { // ----------------------------------------------------------------------------------------------------------------- // Actual columns @@ -12,7 +12,7 @@ class User extends Model public int $id; public string $userName; public \DateTime $joinDate; - public EnumSample $enumValue; + public TestEnum $enumValue; // ----------------------------------------------------------------------------------------------------------------- // Not columns @@ -33,4 +33,12 @@ public function getIsAutoIncrement(): bool { return $this->useAutoIncrement; } + + // ----------------------------------------------------------------------------------------------------------------- + // Table name + + public function getTableName(): string + { + return "users"; + } } \ No newline at end of file diff --git a/tests/Samples/UserAutoTest.php b/tests/Samples/TestUserAuto.php similarity index 92% rename from tests/Samples/UserAutoTest.php rename to tests/Samples/TestUserAuto.php index 1c1874f..97e4725 100644 --- a/tests/Samples/UserAutoTest.php +++ b/tests/Samples/TestUserAuto.php @@ -7,7 +7,7 @@ /** * @table users */ -class UserAutoTest extends Model +class TestUserAuto extends Model { private int $secretNotWritable; diff --git a/tests/Samples/TestUserWithSerialized.php b/tests/Samples/TestUserWithSerialized.php index 1e99af2..d9e617e 100644 --- a/tests/Samples/TestUserWithSerialized.php +++ b/tests/Samples/TestUserWithSerialized.php @@ -10,7 +10,7 @@ class TestUserWithSerialized extends Model // Actual columns public int $id; - public ?DummySerializableType $userName; + public ?TestDummySerializableType $userName; public \DateTime $joinDate; // ----------------------------------------------------------------------------------------------------------------- From ef59533ac933e64421a3f506050930bbcf55616c Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Fri, 19 Jan 2024 16:11:32 +0100 Subject: [PATCH 02/11] wip: design draft - relationships, migrations --- docs/Migrations.md | 44 +++++++++++++++++++++++++++ docs/Relationships.md | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/Migrations.md create mode 100644 docs/Relationships.md diff --git a/docs/Migrations.md b/docs/Migrations.md new file mode 100644 index 0000000..ba8534d --- /dev/null +++ b/docs/Migrations.md @@ -0,0 +1,44 @@ +# Migrations +When you define models in Instarecord, you can also use them to automatically generate and run database migrations. + +That way, you can keep your database schema in sync with your models without having to write any SQL. + +## Defining your models +You define your models as usual - they must inherit from `Model` and contain some fields that translate to database columns. + +### Default assumptions +Instarecord makes the following assumptions by default, and you cannot currently change them: + + - 🔑 `id` is the primary key, and is an auto-incrementing unsigned integer + - 🔤 Column names are `snake_case` versions of the field names (e.g. `firstName` becomes `first_name`) + +### Defining foreign keys +You can define foreign keys by adding a field with an `Relation` attribute: + +```php + Date: Fri, 19 Jan 2024 17:11:58 +0100 Subject: [PATCH 03/11] feat: relationships - attribute & basic writing --- docs/Migrations.md | 6 +- docs/Relationships.md | 8 +- lib/Database/Column.php | 212 +++++++++++++++-------- lib/Model.php | 17 +- lib/Relationships/Relationship.php | 42 +++++ phpunit.php | 16 ++ tests/Relationships/RelationshipTest.php | 47 +++++ tests/Samples/TestAirline.php | 24 +++ tests/Samples/TestPlane.php | 21 +++ 9 files changed, 308 insertions(+), 85 deletions(-) create mode 100644 lib/Relationships/Relationship.php create mode 100644 tests/Relationships/RelationshipTest.php create mode 100644 tests/Samples/TestAirline.php create mode 100644 tests/Samples/TestPlane.php diff --git a/docs/Migrations.md b/docs/Migrations.md index ba8534d..0f180ab 100644 --- a/docs/Migrations.md +++ b/docs/Migrations.md @@ -19,20 +19,20 @@ You can define foreign keys by adding a field with an `Relation` attribute: propertyName = $propertyName; $this->columnName = self::getDefaultColumnName($this->propertyName); $this->defaultValue = $defaultValue; - - $this->determineDataType($rfProp); // apply type + nullable data / set defaults + + $this->applyReflectionData($rfProp); // apply type + nullable data / set defaults $this->timezone = new DateTimeZone( Instarecord::config()->timezone ); } + // ----------------------------------------------------------------------------------------------------------------- + // Init + /** - * Determines and sets the column data type based on the "@var" annotation. + * Determines and sets the column data type, relationships and misc data based on its field definition. * * @param \ReflectionProperty|null $rfProp Property reflection data. - * * @throws ColumnDefinitionException */ - protected function determineDataType(?\ReflectionProperty $rfProp): void + protected function applyReflectionData(?\ReflectionProperty $rfProp): void { $this->dataType = self::TYPE_STRING; $this->referenceType = null; + $this->reflectionEnum = null; + $this->relationshipTarget = null; $this->isNullable = false; - // Process in-code declared php type - if ($rfProp) { - $phpType = $rfProp->getType(); - - if ($phpType) { - $phpTypeStr = $phpType->getName(); - - if ($phpType && $phpTypeStr) { - switch ($phpTypeStr) { - case "bool": - $this->dataType = self::TYPE_BOOLEAN; - break; - case "int": - $this->dataType = self::TYPE_INTEGER; - break; - case "float": - $this->dataType = self::TYPE_DECIMAL; - break; - case "string": - $this->dataType = self::TYPE_STRING; + if (!$rfProp) + // May be null in test scenarios, leave non-nullable string default + return; + + // Check relationship attribute + /** + * @var $relationshipAttr Relationship|null + */ + $relationshipAttr = ($rfProp->getAttributes(Relationship::class)[0] ?? null)?->newInstance(); + + // Check property type + if ($phpType = $rfProp->getType()) { + if ($phpTypeStr = $phpType->getName()) { + switch ($phpTypeStr) { + case "bool": + $this->dataType = self::TYPE_BOOLEAN; + break; + case "int": + $this->dataType = self::TYPE_INTEGER; + break; + case "float": + $this->dataType = self::TYPE_DECIMAL; + break; + case "string": + $this->dataType = self::TYPE_STRING; + break; + case "array": + if ($relationshipAttr) { + $this->dataType = self::TYPE_RELATIONSHIP_MANY; + $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); + } else { + throw new ColumnDefinitionException("Array properties are not supported without a relationship attribute: {$rfProp->getName()}"); + } + break; + default: + if (enum_exists($phpTypeStr)) { + $this->dataType = self::TYPE_ENUM; + $this->reflectionEnum = new \ReflectionEnum($phpTypeStr); + if (!$this->reflectionEnum->isBacked()) { + throw new ColumnDefinitionException("Only backed enums are supported for database serialization, tried to use: {$phpTypeStr}"); + } break; - default: - if (enum_exists($phpTypeStr)) { - $this->dataType = self::TYPE_ENUM; - $this->reflectionEnum = new \ReflectionEnum($phpTypeStr); - if (!$this->reflectionEnum->isBacked()) { - throw new ColumnDefinitionException("Only backed enums are supported for database serialization, tried to use: {$phpTypeStr}"); - } + } else if (class_exists($phpTypeStr)) { + if ($phpTypeStr === "DateTime" || $phpTypeStr === "\DateTime") { + $this->dataType = self::TYPE_DATE_TIME; break; - } else if (class_exists($phpTypeStr)) { - if ($phpTypeStr === "DateTime" || $phpTypeStr === "\DateTime") { - $this->dataType = self::TYPE_DATE_TIME; + } else { + if ($relationshipAttr) { + $this->dataType = self::TYPE_RELATIONSHIP_ONE; + $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); break; - } else { - if ($classImplements = class_implements($phpTypeStr)) { - if (in_array('SoftwarePunt\Instarecord\Serialization\IDatabaseSerializable', $classImplements)) { - $this->dataType = self::TYPE_SERIALIZED_OBJECT; - - try { - $this->referenceType = new $phpTypeStr(); - break; - } catch (Exception $ex) { - throw new ColumnDefinitionException("Objects that implement IDatabaseSerializable must have a default constructor that does not throw errors, in: {$phpTypeStr}, got: {$ex->getMessage()}"); - } + } + if ($classImplements = class_implements($phpTypeStr)) { + if (in_array('SoftwarePunt\Instarecord\Serialization\IDatabaseSerializable', $classImplements)) { + $this->dataType = self::TYPE_SERIALIZED_OBJECT; + try { + $this->referenceType = new $phpTypeStr(); + break; + } catch (Exception $ex) { + throw new ColumnDefinitionException("Objects that implement IDatabaseSerializable must have a default constructor that does not throw errors, in: {$phpTypeStr}, got: {$ex->getMessage()}"); } } - - throw new ColumnDefinitionException("Object property types must implement IDatabaseSerializable, found: {$phpTypeStr}"); } + throw new ColumnDefinitionException("Object property types must implement IDatabaseSerializable or have a Relationship attribute, in: {$rfProp->getName()} of type {$phpTypeStr}"); } - throw new ColumnDefinitionException("Unsupported property type encountered: {$phpTypeStr}"); - } - - if ($phpType instanceof \ReflectionNamedType && $phpType->allowsNull()) { - $this->isNullable = true; - } + } + throw new ColumnDefinitionException("Unsupported property type encountered: {$phpTypeStr}"); + } // End of type switch + if ($phpType instanceof \ReflectionNamedType && $phpType->allowsNull()) { + $this->isNullable = true; } } } } + // ----------------------------------------------------------------------------------------------------------------- + // Getters + /** * Gets whether this column is nullable or not. - * - * @see determineDataType() + * * @return bool + * @see applyReflectionData() */ public function getIsNullable(): bool { @@ -220,7 +251,7 @@ public function getPropertyName(): string /** * Gets the default value for this column. - * + * * @return mixed|null */ public function getDefaultValue() @@ -233,22 +264,22 @@ public function getDefaultValue() // This is a nullable column, no explicit default was set, so we'll set it to NULL for now return null; } - + // It's not a nullable column, we ought to try and set a sensible default value based on its type, if there // is a suitable "empty" or "zero" value for that data type. if ($this->dataType === self::TYPE_STRING) { return ''; } - + if ($this->dataType === self::TYPE_INTEGER || $this->dataType === self::TYPE_DECIMAL) { return 0; } - + if ($this->dataType === self::TYPE_BOOLEAN) { return false; } - + // Unable to find a suitable default, it is up to the developer to set an appropriate value before insertion return null; } @@ -256,8 +287,8 @@ public function getDefaultValue() /** * Gets the column data type. * - * @see Column::TYPE_* * @return string + * @see Column::TYPE_* */ public function getType(): string { @@ -297,13 +328,33 @@ public function hasAuto(): bool return !!$this->getAutoMode(); } + /** + * Gets whether this column is a virtual relationship column. + */ + public function getIsRelationship(): bool + { + return $this->dataType === self::TYPE_RELATIONSHIP_ONE + || $this->dataType === self::TYPE_RELATIONSHIP_MANY; + } + + /** + * Gets whether this column is a virtual relationship column, specifically an array of foreign objects. + */ + public function getIsManyRelationship(): bool + { + return $this->dataType === self::TYPE_RELATIONSHIP_MANY; + } + + // ----------------------------------------------------------------------------------------------------------------- + // Database logic + /** * Formats a PHP value for database insertion according to this column's formatting rules. - * + * * @param mixed $input PHP value * @return string|null Database string for insertion */ - public function formatDatabaseValue($input): ?string + public function formatDatabaseValue(mixed $input): ?string { if (is_object($input)) { if ($input instanceof \DateTime) { @@ -377,16 +428,23 @@ public function formatDatabaseValue($input): ?string return null; } + if ($this->dataType === self::TYPE_RELATIONSHIP_ONE) { + /** + * @var $input Model + */ + return $input->getPrimaryKeyValue(); + } + return strval($input); } /** * Parses a value from the database to PHP format according to this column's formatting rules. - * + * * @param string|null $input Database value, string retrieved from data row * @return mixed PHP value */ - public function parseDatabaseValue(?string $input) + public function parseDatabaseValue(?string $input): mixed { if ($input === null) { return null; @@ -401,7 +459,8 @@ public function parseDatabaseValue(?string $input) if ($dtParsed) { return $dtParsed; } - } catch (Exception $ex) { } + } catch (Exception $ex) { + } // Parse attempt two: alt db format (also used for "time" db fields otherwise they break) try { @@ -410,7 +469,8 @@ public function parseDatabaseValue(?string $input) if ($dtParsed) { return $dtParsed; } - } catch (Exception $ex) { } + } catch (Exception $ex) { + } } // Exhausted options, treat as NULL @@ -447,10 +507,18 @@ public function parseDatabaseValue(?string $input) if ($this->dataType === self::TYPE_DECIMAL) { return floatval($input); } - + + if ($this->getIsRelationship()) { + // Relationships are applied after the fact, so we don't need to do anything here + return null; + } + return strval($input); } + // ----------------------------------------------------------------------------------------------------------------- + // Util + /** * Given a property name, normalizes it to its column name. * The normalization process takes a PHP-like $columnName and converts it to a MySQL compliant "column_name". diff --git a/lib/Model.php b/lib/Model.php index 105fe07..fe06606 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -166,7 +166,6 @@ public function getPropertyValuesWithColumnNames(): array /** * Gets a key/value list of all columns and their values. - * This involves the translation of * * @return array An array containing property values, indexed by property name. */ @@ -178,12 +177,18 @@ public function getColumnValues(): array foreach ($properties as $propertyName => $propertyValue) { $columnInfo = $this->getColumnForPropertyName($propertyName); - if ($columnInfo) { - $columnName = $columnInfo->getColumnName(); - $columnValue = $columnInfo->formatDatabaseValue($propertyValue); + if (!$columnInfo) + // Custom property, not part of the table + continue; - $columns[$columnName] = $columnValue; - } + if ($columnInfo->getIsManyRelationship()) + // "Many" relationships are not columns in our table + continue; + + $columnName = $columnInfo->getColumnName(); + $columnValue = $columnInfo->formatDatabaseValue($propertyValue); + + $columns[$columnName] = $columnValue; } return $columns; diff --git a/lib/Relationships/Relationship.php b/lib/Relationships/Relationship.php new file mode 100644 index 0000000..7e793db --- /dev/null +++ b/lib/Relationships/Relationship.php @@ -0,0 +1,42 @@ +modelClass = $modelClass; + $this->columnName = $columnName; + } + + public function tryLoadModel(): Model + { + if (!class_exists($this->modelClass)) { + throw new \LogicException("Model class '{$this->modelClass}' does not exist."); + } + + $instance = new $this->modelClass(); + + if (!($instance instanceof Model)) { + throw new \LogicException("Model class '{$this->modelClass}' does not extend SoftwarePunt\Instarecord\Model."); + } + + return $instance; + } +} \ No newline at end of file diff --git a/phpunit.php b/phpunit.php index bdd8878..0a384d3 100644 --- a/phpunit.php +++ b/phpunit.php @@ -69,5 +69,21 @@ `modified_at` DATETIME NULL, PRIMARY KEY (`id`), UNIQUE INDEX `user_name_UNIQUE` (`user_name` ASC));'); +$pdo->exec('DROP TABLE IF EXISTS `' . $dsnConfig->database . '`.`planes`;'); +$pdo->exec('DROP TABLE IF EXISTS `' . $dsnConfig->database . '`.`airlines`;'); +$pdo->exec('CREATE TABLE `' . $dsnConfig->database . '`.`airlines` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `iata_code` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `iata_code_UNIQUE` (`iata_code` ASC));'); +$pdo->exec('CREATE TABLE `' . $dsnConfig->database . '`.`planes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `airline_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `registration` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `registration_UNIQUE`(`registration`), + CONSTRAINT `plane_airline` FOREIGN KEY (`airline_id`) REFERENCES `' . $dsnConfig->database . '`.`airlines` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);'); unset($pdo); \ No newline at end of file diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php new file mode 100644 index 0000000..55b4561 --- /dev/null +++ b/tests/Relationships/RelationshipTest.php @@ -0,0 +1,47 @@ +name = "Test Airline"; + $airline->iataCode = "TA"; + $airline->save(); + + // Create some new plane + $plane1 = new TestPlane(); + $plane1->name = "Test Plane 1"; + $plane1->registration = "TP-001"; + $plane1->airline = $airline; + $plane1->save(); + + $plane2 = new TestPlane(); + $plane2->name = "Test Plane 2"; + $plane2->registration = "TP-002"; + $plane2->airline = $airline; + $plane2->save(); + + // Assert that the plane's airline ID is set correctly + $rows = TestPlane::query() + ->queryAllRows(); + $this->assertCount(2, $rows, "Expected 2 planes to be inserted"); + foreach ($rows as $row) { + $this->assertSame($airline->id, $row["airline_id"], "Expected airline ID to be set correctly on created rows"); + } + } +} \ No newline at end of file diff --git a/tests/Samples/TestAirline.php b/tests/Samples/TestAirline.php new file mode 100644 index 0000000..0c8b1aa --- /dev/null +++ b/tests/Samples/TestAirline.php @@ -0,0 +1,24 @@ + Date: Fri, 19 Jan 2024 17:35:46 +0100 Subject: [PATCH 04/11] feat: relationships - very basic loading --- docs/Migrations.md | 44 --------------------- docs/Relationships.md | 49 ++++++------------------ lib/Database/Column.php | 38 ++++++++++++++++-- lib/Database/Table.php | 5 +-- lib/Model.php | 33 ++++++++++++++++ tests/Relationships/RelationshipTest.php | 9 ++++- 6 files changed, 89 insertions(+), 89 deletions(-) delete mode 100644 docs/Migrations.md diff --git a/docs/Migrations.md b/docs/Migrations.md deleted file mode 100644 index 0f180ab..0000000 --- a/docs/Migrations.md +++ /dev/null @@ -1,44 +0,0 @@ -# Migrations -When you define models in Instarecord, you can also use them to automatically generate and run database migrations. - -That way, you can keep your database schema in sync with your models without having to write any SQL. - -## Defining your models -You define your models as usual - they must inherit from `Model` and contain some fields that translate to database columns. - -### Default assumptions -Instarecord makes the following assumptions by default, and you cannot currently change them: - - - 🔑 `id` is the primary key, and is an auto-incrementing unsigned integer - - 🔤 Column names are `snake_case` versions of the field names (e.g. `firstName` becomes `first_name`) - -### Defining foreign keys -You can define foreign keys by adding a field with an `Relation` attribute: - -```php -dataType = self::TYPE_RELATIONSHIP_MANY; $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); + $this->relationshipTarget = $relationshipAttr->modelClass; } else { throw new ColumnDefinitionException("Array properties are not supported without a relationship attribute: {$rfProp->getName()}"); } @@ -184,6 +185,7 @@ protected function applyReflectionData(?\ReflectionProperty $rfProp): void if ($relationshipAttr) { $this->dataType = self::TYPE_RELATIONSHIP_ONE; $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); + $this->relationshipTarget = $relationshipAttr->modelClass; break; } if ($classImplements = class_implements($phpTypeStr)) { @@ -337,6 +339,14 @@ public function getIsRelationship(): bool || $this->dataType === self::TYPE_RELATIONSHIP_MANY; } + /** + * Gets whether this column is a virtual relationship column, specifically a one-to-one relationship. + */ + public function getIsOneRelationship(): bool + { + return $this->dataType === self::TYPE_RELATIONSHIP_ONE; + } + /** * Gets whether this column is a virtual relationship column, specifically an array of foreign objects. */ @@ -345,6 +355,29 @@ public function getIsManyRelationship(): bool return $this->dataType === self::TYPE_RELATIONSHIP_MANY; } + /** + * Gets the target class for the relationship, if this is a relationship column. + */ + public function getRelationshipClass(): ?string + { + return $this->relationshipTarget; + } + + public function getRelationshipReference(): Model + { + $targetClass = $this->relationshipTarget; + if (!$targetClass) { + throw new \LogicException("Attempted to get a relationship reference for a column that has no defined relationship: {$this->columnName}"); + } + + $targetRef = new $targetClass(); + if (!($targetRef instanceof Model)) { + throw new \LogicException("Attempted to load a relationship that is not a model: {$targetClass}"); + } + + return $targetRef; + } + // ----------------------------------------------------------------------------------------------------------------- // Database logic @@ -508,9 +541,8 @@ public function parseDatabaseValue(?string $input): mixed return floatval($input); } - if ($this->getIsRelationship()) { - // Relationships are applied after the fact, so we don't need to do anything here - return null; + if ($this->getIsOneRelationship()) { + return $this->getRelationshipReference()::fetch($input); } return strval($input); diff --git a/lib/Database/Table.php b/lib/Database/Table.php index 906e2f1..098848c 100644 --- a/lib/Database/Table.php +++ b/lib/Database/Table.php @@ -137,9 +137,9 @@ public function getColumnByName(string $columnName): ?Column } /** - * @var array + * @var Table[] */ - public static $tableInfoCache = []; + public static array $tableInfoCache = []; /** * @param string $modelClassName @@ -150,7 +150,6 @@ public static function getTableInfo(string $modelClassName): Table if (!isset(self::$tableInfoCache[$modelClassName])) { self::$tableInfoCache[$modelClassName] = new Table($modelClassName); } - return self::$tableInfoCache[$modelClassName]; } diff --git a/lib/Model.php b/lib/Model.php index fe06606..23726f7 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -769,4 +769,37 @@ protected function runAutoApplicator(string $reason): bool return $anyChanges; } + + // ----------------------------------------------------------------------------------------------------------------- + // Relationships + + public function loadRelationships(): void + { + foreach ($this->_tableInfo->getColumns() as $column) { + if ($column->getIsRelationship()) { + $this->loadRelationship($column); + } + } + } + + public function loadRelationship(Column $column): void + { + + + $propName = $column->getPropertyName(); + + if ($column->getIsOneRelationship()) { + // We need to load a single object + $this->$propName = $targetRef::fetch($this->$propName); + } else if ($column->getIsManyRelationship()) { + // We need to load an array of objects + } + } + + public function loadRelationshipByColumn(string $columnName): void + { + $this->loadRelationship( + $this->getColumnByName($columnName) + ); + } } \ No newline at end of file diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index 55b4561..b5f7b21 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -13,7 +13,7 @@ class RelationshipTest extends TestCase /** * @runInSeparateProcess */ - public function testRelationshipWrite() + public function testRelationshipWriteAndRead() { Instarecord::config(new TestDatabaseConfig()); @@ -43,5 +43,12 @@ public function testRelationshipWrite() foreach ($rows as $row) { $this->assertSame($airline->id, $row["airline_id"], "Expected airline ID to be set correctly on created rows"); } + + // Query a plane model and ensure the airline is loaded + $plane1Reload = TestPlane::query() + ->where("id = ?", $plane1->id) + ->querySingleModel(); + $this->assertEquals($plane1->airline->id, $plane1Reload->airline->id, + "Expected airline to be loaded and set automatically"); } } \ No newline at end of file From 7fe77c10fd0a290bcda8c85bc31f0c7900da5397 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 00:06:41 +0100 Subject: [PATCH 05/11] feat: relationship load batcher --- docs/Relationships.md | 4 +- lib/Database/Column.php | 9 ++- lib/Database/ModelQuery.php | 11 ++- lib/Database/Table.php | 16 +++++ lib/Model.php | 50 +++---------- lib/Relationships/RelationshipBatch.php | 85 +++++++++++++++++++++++ lib/Relationships/RelationshipBatcher.php | 62 +++++++++++++++++ tests/Relationships/RelationshipTest.php | 8 +++ 8 files changed, 194 insertions(+), 51 deletions(-) create mode 100644 lib/Relationships/RelationshipBatch.php create mode 100644 lib/Relationships/RelationshipBatcher.php diff --git a/docs/Relationships.md b/docs/Relationships.md index 3dd37af..f3f9ba7 100644 --- a/docs/Relationships.md +++ b/docs/Relationships.md @@ -39,6 +39,6 @@ Coming soon because we can do better (WIP). ### Eager loading by default When you query a model, or collection of models, all relationships are loaded automatically. This is called **eager loading**. -This will cause extra queries to be executed, and can be quite inefficient if you don't need the relationships or have a lot of data. +Eager loading will always cause extra queries to be executed, and can be quite inefficient if you don't need the relationships or have a lot of data. -Additionally, Instarecord does not currently batch these queries so it's quite awful for performance (WIP). \ No newline at end of file +If you use `Model::all()` or `ModelQuery::queryAllModels()`, Instarecord will batch the queries to load all relationships at once. diff --git a/lib/Database/Column.php b/lib/Database/Column.php index 5bdf39e..88d8315 100644 --- a/lib/Database/Column.php +++ b/lib/Database/Column.php @@ -475,9 +475,10 @@ public function formatDatabaseValue(mixed $input): ?string * Parses a value from the database to PHP format according to this column's formatting rules. * * @param string|null $input Database value, string retrieved from data row + * @param bool $loadRelationships If true, automatically load defined relationships (causing additional queries). * @return mixed PHP value */ - public function parseDatabaseValue(?string $input): mixed + public function parseDatabaseValue(?string $input, bool $loadRelationships = false): mixed { if ($input === null) { return null; @@ -542,7 +543,11 @@ public function parseDatabaseValue(?string $input): mixed } if ($this->getIsOneRelationship()) { - return $this->getRelationshipReference()::fetch($input); + if ($loadRelationships) { + return $this->getRelationshipReference()::fetch($input); + } else { + return null; + } } return strval($input); diff --git a/lib/Database/ModelQuery.php b/lib/Database/ModelQuery.php index aec9028..62a9495 100644 --- a/lib/Database/ModelQuery.php +++ b/lib/Database/ModelQuery.php @@ -5,6 +5,7 @@ use SoftwarePunt\Instarecord\Model; use SoftwarePunt\Instarecord\Models\IReadOnlyModel; use SoftwarePunt\Instarecord\Models\ModelAccessException; +use SoftwarePunt\Instarecord\Relationships\RelationshipBatcher; /** * A model-specific query. @@ -71,13 +72,9 @@ public function wherePrimaryKeyMatches(Model $instance): ModelQuery public function queryAllModels(): array { $rows = $this->queryAllRows(); - $models = []; - - foreach ($rows as $row) { - $models[] = new $this->modelName($row); - } - - return $models; + // Wrap in batch loader for relationships (if any) to optimize queries + $relationshipBatcher = new RelationshipBatcher($this->referenceModel, $rows); + return $relationshipBatcher->loadAllModels(); } /** diff --git a/lib/Database/Table.php b/lib/Database/Table.php index 098848c..30f6970 100644 --- a/lib/Database/Table.php +++ b/lib/Database/Table.php @@ -98,6 +98,22 @@ public function getColumns(): array return $columns; } + /** + * Gets columns with a defined relationship. + * + * @return Column[] + */ + public function getRelationshipColumns(): array + { + $columns = []; + foreach ($this->getColumns() as $column) { + if ($column->getIsRelationship()) { + $columns[$column->getColumnName()] = $column; + } + } + return $columns; + } + /** * Gets column information by property name. * diff --git a/lib/Model.php b/lib/Model.php index 23726f7..2511b4b 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -32,12 +32,13 @@ class Model * Initializes a new instance of this model which can be inserted into the database. * * @param array|null $initialValues Optionally, an array of initial property values to set on the model. + * @param bool $loadRelationships If true, automatically load defined relationships (causing additional queries). */ - public function __construct(?array $initialValues = []) + public function __construct(?array $initialValues = [], bool $loadRelationships = true) { $this->_tableInfo = Table::getTableInfo(get_class($this)); - $this->setInitialValues($initialValues); + $this->setInitialValues($initialValues, $loadRelationships); $this->markAllPropertiesClean(); } @@ -70,9 +71,10 @@ public function getIsAutoIncrement(): bool /** * Applies a set of initial values as properties on this model. * - * @param array $initialValues A list of properties and their values, or columns and their values, or a mix thereof. + * @param array|null $initialValues A list of properties and their values, or columns and their values, or a mix thereof. + * @param bool $loadRelationships If true, automatically load defined relationships (causing additional queries). */ - protected function setInitialValues(?array $initialValues): void + protected function setInitialValues(?array $initialValues, bool $loadRelationships = true): void { foreach ($this->getTableInfo()->getColumns() as $column) { $propertyName = $column->getPropertyName(); @@ -85,7 +87,7 @@ protected function setInitialValues(?array $initialValues): void } if ($initialValues) { - $this->setColumnValues($initialValues); + $this->setColumnValues($initialValues, $loadRelationships); } } @@ -198,8 +200,9 @@ public function getColumnValues(): array * Applies a set of database values to this instance. * * @param array $values + * @param bool $loadRelationships If true, automatically load defined relationships (causing additional queries). */ - public function setColumnValues(array $values): void + public function setColumnValues(array $values, bool $loadRelationships = false): void { foreach ($values as $nameInArray => $valueInArray) { // Can we find the column by its name? @@ -217,7 +220,7 @@ public function setColumnValues(array $values): void // Set the value, parsing it where needed $propertyName = $columnInfo->getPropertyName(); - $propertyValue = $columnInfo->parseDatabaseValue($valueInArray); + $propertyValue = $columnInfo->parseDatabaseValue($valueInArray, $loadRelationships); // [php-7.4]: Only assign default value if it's not null, or if null is explicitly allowed if (($propertyValue !== null) || ($columnInfo->getIsNullable() && $propertyValue === null)) { @@ -769,37 +772,4 @@ protected function runAutoApplicator(string $reason): bool return $anyChanges; } - - // ----------------------------------------------------------------------------------------------------------------- - // Relationships - - public function loadRelationships(): void - { - foreach ($this->_tableInfo->getColumns() as $column) { - if ($column->getIsRelationship()) { - $this->loadRelationship($column); - } - } - } - - public function loadRelationship(Column $column): void - { - - - $propName = $column->getPropertyName(); - - if ($column->getIsOneRelationship()) { - // We need to load a single object - $this->$propName = $targetRef::fetch($this->$propName); - } else if ($column->getIsManyRelationship()) { - // We need to load an array of objects - } - } - - public function loadRelationshipByColumn(string $columnName): void - { - $this->loadRelationship( - $this->getColumnByName($columnName) - ); - } } \ No newline at end of file diff --git a/lib/Relationships/RelationshipBatch.php b/lib/Relationships/RelationshipBatch.php new file mode 100644 index 0000000..21c6138 --- /dev/null +++ b/lib/Relationships/RelationshipBatch.php @@ -0,0 +1,85 @@ +columnName = $column->getColumnName(); + $this->propertyName = $column->getPropertyName(); + $this->relationshipClass = $column->getRelationshipClass(); + + $this->modelPkToFk = []; + $this->distinctFkValues = []; + } + + /** + * Checks a model after it has been loaded from the database, and prepares the batch query. + * + * @param Model $model + * @param array $backingRow + * @return void + */ + public function checkModel(Model $model, array $backingRow): void + { + $backingFk = $backingRow[$this->columnName]; + + if ($backingFk === null) { + // Database value is null, so set property to null - no further action required + $model->{$this->propertyName} = null; + return; + } + + $modelPk = $model->getPrimaryKeyValue(); + $this->modelPkToFk[$modelPk] = $backingFk; + if (!in_array($backingFk, $this->distinctFkValues)) + $this->distinctFkValues[] = $backingFk; + } + + /** + * Executes the combined/batch query, applying the results to the given models. + * + * @param array $models + * @return void + */ + public function queryAndApply(array $models): void + { + if (empty($this->distinctFkValues)) + // Nothing to do / all nulls + return; + + $referenceModel = new $this->relationshipClass(); + if (!$referenceModel instanceof Model) + throw new \Exception("Relationship class {$this->relationshipClass} is not a Model."); + + $referencePkColumn = $referenceModel->getPrimaryKeyColumnName(); + + $results = $referenceModel::query() + ->where("`{$referencePkColumn}` IN (?)", $this->distinctFkValues) + ->queryAllModelsIndexed(); + + $propName = $this->propertyName; + + foreach ($models as $model) { + /** + * @var $model Model + */ + $modelPk = $model->getPrimaryKeyValue(); + $backingFk = $this->modelPkToFk[$modelPk]; + $fkResult = $results[$backingFk] ?? null; + + $model->$propName = $fkResult; + } + } +} \ No newline at end of file diff --git a/lib/Relationships/RelationshipBatcher.php b/lib/Relationships/RelationshipBatcher.php new file mode 100644 index 0000000..fa90525 --- /dev/null +++ b/lib/Relationships/RelationshipBatcher.php @@ -0,0 +1,62 @@ +referenceModel = $referenceModel; + $this->modelName = $referenceModel::class; + $this->rows = $rows; + + // Determine relationships + $this->relationshipColumns = $this->referenceModel->getTableInfo()->getRelationshipColumns(); + $this->hasRelationships = !empty($this->relationshipColumns); + + // Create batches + $this->batches = []; + if ($this->hasRelationships) { + foreach ($this->relationshipColumns as $relationshipColumn) { + $this->batches[] = new RelationshipBatch($relationshipColumn); + } + } + } + + /** + * @return Model[] + */ + public function loadAllModels(): array + { + $models = []; + + foreach ($this->rows as $row) { + $model = new $this->modelName($row, loadRelationships: false); + + foreach ($this->batches as $batch) { + $batch->checkModel($model, $row); + } + + $models[] = $model; + } + + foreach ($this->batches as $batch) { + $batch->queryAndApply($models); + } + + return $models; + } +} \ No newline at end of file diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index b5f7b21..dc3ff2f 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -50,5 +50,13 @@ public function testRelationshipWriteAndRead() ->querySingleModel(); $this->assertEquals($plane1->airline->id, $plane1Reload->airline->id, "Expected airline to be loaded and set automatically"); + + // Test load with query all optimizations in place + $allPlanesInDb = TestPlane::query() + ->queryAllModels(); + $this->assertCount(2, $allPlanesInDb, "Expected 2 planes to be loaded"); + foreach ($allPlanesInDb as $plane) { + $this->assertEquals($airline->id, $plane->airline->id, "Expected airline to be loaded and set automatically"); + } } } \ No newline at end of file From e1997baed11768f983914d193ab542abca91d821 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 00:29:16 +0100 Subject: [PATCH 06/11] feat: greatly simplify one-to-one relationships --- docs/Relationships.md | 24 ++++---- lib/Database/Column.php | 73 +++++++----------------- lib/Model.php | 4 -- lib/Relationships/Relationship.php | 42 -------------- tests/Relationships/RelationshipTest.php | 3 - tests/Samples/TestAirline.php | 7 --- tests/Samples/TestPlane.php | 3 - 7 files changed, 32 insertions(+), 124 deletions(-) delete mode 100644 lib/Relationships/Relationship.php diff --git a/docs/Relationships.md b/docs/Relationships.md index f3f9ba7..ac7ca17 100644 --- a/docs/Relationships.md +++ b/docs/Relationships.md @@ -1,5 +1,7 @@ # Relationships -You can define relationships between models using the `Relationship` attribute pointing to another model. +You can define relationships between models to enable automatic loading of related data. + +It's an optional feature; you can use Instarecord without relationships if you want to. This will give you greater control over the queries you execute, and can be more efficient if you don't need the related data. ## Defining relationships Relationships can generally be categorized into two types: @@ -7,32 +9,30 @@ Relationships can generally be categorized into two types: - **One-to-one**: A single object of type `A` is related to a single object of type `B`. For example, a `User` has a single `Profile`. - **One-to-many**: A single object of type `A` is related to multiple objects of type `B`. For example, a `User` has many `Posts`. +**Many-to-many** relationships are implicitly supported; simply define a model that represents the connecting table and define two one-to-many relationships. + ### One-to-one -You can define a one-to-one relationship by adding a field with an `Relationship` attribute: +You can define an X-to-one relationship by simply adding a field that references another model: ```php getAttributes(Relationship::class)[0] ?? null)?->newInstance(); - // Check property type if ($phpType = $rfProp->getType()) { if ($phpTypeStr = $phpType->getName()) { @@ -161,14 +154,7 @@ protected function applyReflectionData(?\ReflectionProperty $rfProp): void $this->dataType = self::TYPE_STRING; break; case "array": - if ($relationshipAttr) { - $this->dataType = self::TYPE_RELATIONSHIP_MANY; - $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); - $this->relationshipTarget = $relationshipAttr->modelClass; - } else { - throw new ColumnDefinitionException("Array properties are not supported without a relationship attribute: {$rfProp->getName()}"); - } - break; + throw new ColumnDefinitionException("Array properties are not supported: {$rfProp->getName()}"); default: if (enum_exists($phpTypeStr)) { $this->dataType = self::TYPE_ENUM; @@ -179,28 +165,26 @@ protected function applyReflectionData(?\ReflectionProperty $rfProp): void break; } else if (class_exists($phpTypeStr)) { if ($phpTypeStr === "DateTime" || $phpTypeStr === "\DateTime") { + // DateTime handling $this->dataType = self::TYPE_DATE_TIME; break; - } else { - if ($relationshipAttr) { - $this->dataType = self::TYPE_RELATIONSHIP_ONE; - $this->columnName = $relationshipAttr->columnName ?? ($this->columnName . "_id"); - $this->relationshipTarget = $relationshipAttr->modelClass; + } else if (($classParents = class_parents($phpTypeStr)) && in_array('SoftwarePunt\Instarecord\Model', $classParents)) { + // Object reference to another model: One-to-one relationship + $this->dataType = self::TYPE_RELATIONSHIP; + $this->columnName = $this->columnName . "_id"; + $this->relationshipTarget = $phpTypeStr; + break; + } else if (($classImplements = class_implements($phpTypeStr)) && in_array('SoftwarePunt\Instarecord\Serialization\IDatabaseSerializable', $classImplements)) { + // Object reference to a serializable object + $this->dataType = self::TYPE_SERIALIZED_OBJECT; + try { + $this->referenceType = new $phpTypeStr(); break; + } catch (Exception $ex) { + throw new ColumnDefinitionException("Objects that implement IDatabaseSerializable must have a default constructor that does not throw errors, in: {$phpTypeStr}, got: {$ex->getMessage()}"); } - if ($classImplements = class_implements($phpTypeStr)) { - if (in_array('SoftwarePunt\Instarecord\Serialization\IDatabaseSerializable', $classImplements)) { - $this->dataType = self::TYPE_SERIALIZED_OBJECT; - try { - $this->referenceType = new $phpTypeStr(); - break; - } catch (Exception $ex) { - throw new ColumnDefinitionException("Objects that implement IDatabaseSerializable must have a default constructor that does not throw errors, in: {$phpTypeStr}, got: {$ex->getMessage()}"); - } - } - } - throw new ColumnDefinitionException("Object property types must implement IDatabaseSerializable or have a Relationship attribute, in: {$rfProp->getName()} of type {$phpTypeStr}"); } + throw new ColumnDefinitionException("Referenced object is not a Model and not IDatabaseSerializable - not supported by Instarecord, in: {$rfProp->getName()} of type {$phpTypeStr}"); } throw new ColumnDefinitionException("Unsupported property type encountered: {$phpTypeStr}"); } // End of type switch @@ -335,24 +319,7 @@ public function hasAuto(): bool */ public function getIsRelationship(): bool { - return $this->dataType === self::TYPE_RELATIONSHIP_ONE - || $this->dataType === self::TYPE_RELATIONSHIP_MANY; - } - - /** - * Gets whether this column is a virtual relationship column, specifically a one-to-one relationship. - */ - public function getIsOneRelationship(): bool - { - return $this->dataType === self::TYPE_RELATIONSHIP_ONE; - } - - /** - * Gets whether this column is a virtual relationship column, specifically an array of foreign objects. - */ - public function getIsManyRelationship(): bool - { - return $this->dataType === self::TYPE_RELATIONSHIP_MANY; + return $this->dataType === self::TYPE_RELATIONSHIP; } /** @@ -461,7 +428,7 @@ public function formatDatabaseValue(mixed $input): ?string return null; } - if ($this->dataType === self::TYPE_RELATIONSHIP_ONE) { + if ($this->dataType === self::TYPE_RELATIONSHIP) { /** * @var $input Model */ @@ -542,7 +509,7 @@ public function parseDatabaseValue(?string $input, bool $loadRelationships = fal return floatval($input); } - if ($this->getIsOneRelationship()) { + if ($this->getIsRelationship()) { if ($loadRelationships) { return $this->getRelationshipReference()::fetch($input); } else { diff --git a/lib/Model.php b/lib/Model.php index 2511b4b..aa8ee41 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -183,10 +183,6 @@ public function getColumnValues(): array // Custom property, not part of the table continue; - if ($columnInfo->getIsManyRelationship()) - // "Many" relationships are not columns in our table - continue; - $columnName = $columnInfo->getColumnName(); $columnValue = $columnInfo->formatDatabaseValue($propertyValue); diff --git a/lib/Relationships/Relationship.php b/lib/Relationships/Relationship.php deleted file mode 100644 index 7e793db..0000000 --- a/lib/Relationships/Relationship.php +++ /dev/null @@ -1,42 +0,0 @@ -modelClass = $modelClass; - $this->columnName = $columnName; - } - - public function tryLoadModel(): Model - { - if (!class_exists($this->modelClass)) { - throw new \LogicException("Model class '{$this->modelClass}' does not exist."); - } - - $instance = new $this->modelClass(); - - if (!($instance instanceof Model)) { - throw new \LogicException("Model class '{$this->modelClass}' does not extend SoftwarePunt\Instarecord\Model."); - } - - return $instance; - } -} \ No newline at end of file diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index dc3ff2f..4ff0de1 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -10,9 +10,6 @@ class RelationshipTest extends TestCase { - /** - * @runInSeparateProcess - */ public function testRelationshipWriteAndRead() { Instarecord::config(new TestDatabaseConfig()); diff --git a/tests/Samples/TestAirline.php b/tests/Samples/TestAirline.php index 0c8b1aa..c524308 100644 --- a/tests/Samples/TestAirline.php +++ b/tests/Samples/TestAirline.php @@ -3,7 +3,6 @@ namespace SoftwarePunt\Instarecord\Tests\Samples; use SoftwarePunt\Instarecord\Model; -use SoftwarePunt\Instarecord\Relationships\Relationship; class TestAirline extends Model { @@ -11,12 +10,6 @@ class TestAirline extends Model public string $name; public string $iataCode; - /** - * @var TestPlane[] - */ - #[Relationship(TestPlane::class)] - public array $planes; - public function getTableName(): string { return "airlines"; diff --git a/tests/Samples/TestPlane.php b/tests/Samples/TestPlane.php index 3eeb6c4..cdeb754 100644 --- a/tests/Samples/TestPlane.php +++ b/tests/Samples/TestPlane.php @@ -2,14 +2,11 @@ namespace SoftwarePunt\Instarecord\Tests\Samples; - use SoftwarePunt\Instarecord\Model; -use SoftwarePunt\Instarecord\Relationships\Relationship; class TestPlane extends Model { public int $id; - #[Relationship(TestAirline::class)] public TestAirline $airline; public string $name; public string $registration; From ff00c7d30971b4a5c962e03262c50fe9d8a79262 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 01:31:22 +0100 Subject: [PATCH 07/11] feat: HasMany relationship util --- docs/Relationships.md | 37 ++++-- lib/Database/ModelQuery.php | 8 +- lib/Model.php | 33 ++++++ lib/Relationships/HasManyRelationship.php | 132 ++++++++++++++++++++++ tests/Relationships/RelationshipTest.php | 60 +++++++++- tests/Samples/TestAirline.php | 6 + 6 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 lib/Relationships/HasManyRelationship.php diff --git a/docs/Relationships.md b/docs/Relationships.md index ac7ca17..55e378c 100644 --- a/docs/Relationships.md +++ b/docs/Relationships.md @@ -11,8 +11,8 @@ Relationships can generally be categorized into two types: **Many-to-many** relationships are implicitly supported; simply define a model that represents the connecting table and define two one-to-many relationships. -### One-to-one -You can define an X-to-one relationship by simply adding a field that references another model: +## One-to-one +You can define a one-to-one relationship by simply adding a field that references another model: ```php hasMany(Post::class); + } +} +``` -If you use `queryAllModels()`, Instarecord will batch the queries to load all relationships at once. If you `fetch()` or otherwise load a single model, Instarecord will load the relationships one-by-one. \ No newline at end of file +Rather than defining a property, you define a method that returns a `ManyRelationship` instance. This instance will be cached and reused for the lifetime of the model. \ No newline at end of file diff --git a/lib/Database/ModelQuery.php b/lib/Database/ModelQuery.php index 62a9495..9d978d4 100644 --- a/lib/Database/ModelQuery.php +++ b/lib/Database/ModelQuery.php @@ -14,17 +14,13 @@ class ModelQuery extends Query { /** * The fully qualified class name of the model. - * - * @var string */ - protected $modelName; + protected string $modelName; /** * A blank reference for the model we are managing. - * - * @var Model */ - protected $referenceModel; + protected Model $referenceModel; /** * Constructs a new model query. diff --git a/lib/Model.php b/lib/Model.php index aa8ee41..304bcf7 100644 --- a/lib/Model.php +++ b/lib/Model.php @@ -7,6 +7,8 @@ use SoftwarePunt\Instarecord\Database\ModelQuery; use SoftwarePunt\Instarecord\Database\Table; use SoftwarePunt\Instarecord\Models\ModelLogicException; +use SoftwarePunt\Instarecord\Relationships\HasManyRelationship; +use SoftwarePunt\Instarecord\Utils\TextTransforms; /** * The base class for all Softwarepunt models. @@ -768,4 +770,35 @@ protected function runAutoApplicator(string $reason): bool return $anyChanges; } + + // ----------------------------------------------------------------------------------------------------------------- + // Relationships + + /** + * @var HasManyRelationship[] + */ + private array $hasManyRelationships = []; + + /** + * Helper method to create or retrieve a "has many" relationship for this model instance. + * + * @param string $targetClass + * @param string|null $foreignKey + * @return HasManyRelationship + */ + public function hasMany(string $targetClass, ?string $foreignKey = null): HasManyRelationship + { + if (!$foreignKey) { + $tableName = $this->getTableName(); + $singular = TextTransforms::singularize($tableName); + $foreignKey = "{$singular}_id"; // e.g. if we are User, this would be "user_id" + } + + $instanceKey = "{$targetClass}::{$foreignKey}"; + + if (!isset($this->hasManyRelationships[$instanceKey])) { + $this->hasManyRelationships[$instanceKey] = new HasManyRelationship($this, $targetClass, $foreignKey); + } + return $this->hasManyRelationships[$instanceKey]; + } } \ No newline at end of file diff --git a/lib/Relationships/HasManyRelationship.php b/lib/Relationships/HasManyRelationship.php new file mode 100644 index 0000000..cce35cd --- /dev/null +++ b/lib/Relationships/HasManyRelationship.php @@ -0,0 +1,132 @@ +hostModel = $hostModel; + $this->targetModelClass = $targetModelClass; + $this->foreignKeyColumn = $foreignKeyColumn; + + $this->referenceModel = new $targetModelClass(); + $this->loadedModels = []; + $this->isFullyLoaded = false; + } + + // ----------------------------------------------------------------------------------------------------------------- + // Query + + public function getPrimaryKeyValue(): mixed + { + return $this->hostModel->getPrimaryKeyValue(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // API - Primary + + /** + * Begins a query for this relationship, with a predefined WHERE clause for the foreign key. + * + * @return ModelQuery + */ + public function query(): ModelQuery + { + $pkVal = $this->getPrimaryKeyValue(); + + if (empty($pkVal)) + throw new \InvalidArgumentException("Cannot query relationship without having primary key set"); + + return $this->referenceModel::query() + ->where("{$this->foreignKeyColumn} = ?", $pkVal); + } + + /** + * Gets all models in this relationship. + * If not previously loaded, causes all or the remaining models to be loaded from the database. + * + * @return Model[] + */ + public function all(): array + { + if ($this->isFullyLoaded) { + // Full model load - return cached + return $this->loadedModels; + } + + $loadQuery = $this->query(); + + if (!empty($this->loadedModels)) { + // Partial model list loaded - check what we need to load + $loadedIds = array_keys($this->loadedModels); + $missingModels = $loadQuery->andWhere("id NOT IN (?)", $loadedIds) + ->queryAllModelsIndexed(); + foreach ($missingModels as $missingModel) { + $this->loadedModels[$missingModel->getPrimaryKeyValue()] = $missingModel; + } + } else { + // No models loaded - load all + $this->loadedModels = $loadQuery->queryAllModelsIndexed(); + } + + $this->isFullyLoaded = true; + return $this->loadedModels; + } + + // ----------------------------------------------------------------------------------------------------------------- + // API - Utils + + /** + * Reset/invalidate the loaded model cache. + */ + public function reset(): void + { + $this->loadedModels = []; + $this->isFullyLoaded = false; + } + + /** + * Cache a model for this relationship. + */ + public function addLoaded(Model $model): void + { + if ($model::class !== $this->targetModelClass) { + throw new \InvalidArgumentException("Cannot add different model to relationship of type {$this->targetModelClass}"); + } + + $pkVal = $model->getPrimaryKeyValue(); + + if (empty($pkVal)) { + throw new \InvalidArgumentException("Cannot add model with empty primary key to relationship of type {$this->targetModelClass}"); + } + + $this->loadedModels[$pkVal] = $model; + } + + /** + * Cache an array of models for this relationship. + */ + public function addLoadedArray(array $models): void + { + foreach ($models as $model) { + $this->addLoaded($model); + } + } +} \ No newline at end of file diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index 4ff0de1..2ef8e13 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -10,7 +10,10 @@ class RelationshipTest extends TestCase { - public function testRelationshipWriteAndRead() + /** + * @runInSeparateProcess + */ + public function testOneToOneRelationship() { Instarecord::config(new TestDatabaseConfig()); @@ -48,7 +51,7 @@ public function testRelationshipWriteAndRead() $this->assertEquals($plane1->airline->id, $plane1Reload->airline->id, "Expected airline to be loaded and set automatically"); - // Test load with query all optimizations in place + // Test load with queryAllModels (batch optimizations) $allPlanesInDb = TestPlane::query() ->queryAllModels(); $this->assertCount(2, $allPlanesInDb, "Expected 2 planes to be loaded"); @@ -56,4 +59,57 @@ public function testRelationshipWriteAndRead() $this->assertEquals($airline->id, $plane->airline->id, "Expected airline to be loaded and set automatically"); } } + + /** + * @runInSeparateProcess + */ + public function testHasManyRelationship() + { + Instarecord::config(new TestDatabaseConfig()); + + // Create a new airline + $airline = new TestAirline(); + $airline->name = "Test Airline"; + $airline->iataCode = "TA"; + $airline->save(); + + // Check the initial "has many" relationship state + $initialMany = $airline->planes(); + $this->assertSame("airline_id", $initialMany->foreignKeyColumn, + "Foreign key name should be automatically derived if left null based on host table name"); + $this->assertEmpty($initialMany->all(), + "Expected no planes to be loaded initially"); + + // Create some new plane + $plane1 = new TestPlane(); + $plane1->name = "Test Plane 1"; + $plane1->registration = "TP-001"; + $plane1->airline = $airline; + $plane1->save(); + + $plane2 = new TestPlane(); + $plane2->name = "Test Plane 2"; + $plane2->registration = "TP-002"; + $plane2->airline = $airline; + $plane2->save(); + + // Retrieve the "has many" relationship + $afterMany = $airline->planes(); + $this->assertSame($initialMany, $afterMany, + "Expected the same cached relationship instance to be returned when called again"); + $this->assertEmpty($initialMany->all(), + "Expected cached (empty) planes list to be returned if we ask for all()"); + $initialMany->reset(); + $this->assertCount(2, $initialMany->all(), + "Expected 2 planes to be loaded after reset() and all()"); + + // Has many relationship: partial load test + $partialMany = $airline->planes(); + $partialMany->reset(); + $partialMany->addLoaded($plane2); + $this->assertCount(2, $initialMany->all(), + "Expected 2 planes to be loaded after reset(), addLoaded() and all()"); + $this->assertSame($plane2, $partialMany->all()[$plane2->id], + "Expected original, cached relationship to be returned from addLoaded() call"); + } } \ No newline at end of file diff --git a/tests/Samples/TestAirline.php b/tests/Samples/TestAirline.php index c524308..ee832bb 100644 --- a/tests/Samples/TestAirline.php +++ b/tests/Samples/TestAirline.php @@ -3,6 +3,7 @@ namespace SoftwarePunt\Instarecord\Tests\Samples; use SoftwarePunt\Instarecord\Model; +use SoftwarePunt\Instarecord\Relationships\HasManyRelationship; class TestAirline extends Model { @@ -14,4 +15,9 @@ public function getTableName(): string { return "airlines"; } + + public function planes(): HasManyRelationship + { + return $this->hasMany(TestPlane::class); + } } \ No newline at end of file From a26cad2e84bf678e0e28a9750ae90456c8536e56 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 01:53:23 +0100 Subject: [PATCH 08/11] feat: HasMany -> all(), fetch(), query(), result hooks --- docs/Relationships.md | 45 +++++++++++++++- lib/Database/ModelQuery.php | 62 ++++++++++++++++++----- lib/Relationships/HasManyRelationship.php | 30 ++++++++++- tests/Relationships/RelationshipTest.php | 8 +++ 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/docs/Relationships.md b/docs/Relationships.md index 55e378c..5e01d9e 100644 --- a/docs/Relationships.md +++ b/docs/Relationships.md @@ -58,4 +58,47 @@ class User extends Model } ``` -Rather than defining a property, you define a method that returns a `ManyRelationship` instance. This instance will be cached and reused for the lifetime of the model. \ No newline at end of file +Rather than defining a property, you define a method that returns a `ManyRelationship` instance. This instance will be cached and reused for the lifetime of the model. + +### Querying +You can use the `query()` method on the relationship to get a query builder for the related model: + +```php +$user->posts()->query(); // ModelQuery +``` + +By default, the query results will be hooked. This means when your query loads any models, they are automatically added to the relationship's cache. + +### Fetch all +You can use the `all()` method on the relationship to get an array of all results: + +```php +$user->posts()->all(); // Post[] +``` + +This may cause a large query, although only records that are not already cached will be loaded. + +This list will be cached on the relationship, so subsequent calls will not cause additional queries to be executed. + +### Fetch one +You can use the `fetch()` method on the relationship to get a single result: + +```php +$user->posts()->fetch(123); // Post|null +``` + +If the post is already cached (e.g. by an earlier `all()` call or query), + +### Cache management +You can invalidate and clear the cache for a relationship by calling `reset()`: + +```php +$user->posts()->reset(); +``` + +You can also manually add items to the cache: + +```php +$user->posts()->addLoaded($post); +$user->posts()->addLoadedArray($posts); +``` \ No newline at end of file diff --git a/lib/Database/ModelQuery.php b/lib/Database/ModelQuery.php index 9d978d4..87eb8af 100644 --- a/lib/Database/ModelQuery.php +++ b/lib/Database/ModelQuery.php @@ -22,6 +22,11 @@ class ModelQuery extends Query */ protected Model $referenceModel; + /** + * @var callable[] An array of callbacks to invoke when a model is loaded. + */ + protected array $resultHooks = []; + /** * Constructs a new model query. * @@ -38,6 +43,7 @@ public function __construct(Connection $connection, string $modelName) $this->modelName = $modelName; $this->referenceModel = new $modelName; + $this->resultHooks = []; if (!$this->referenceModel instanceof Model) { throw new DatabaseException("ModelQuery: Invalid model class, does not extend from Model: {$modelName}"); @@ -47,6 +53,9 @@ public function __construct(Connection $connection, string $modelName) $this->from($this->referenceModel->getTableName()); } + // ----------------------------------------------------------------------------------------------------------------- + // Query - Wheres + /** * Adds a WHERE constraint to match the primary key in the given model instance. * @@ -60,6 +69,9 @@ public function wherePrimaryKeyMatches(Model $instance): ModelQuery return $this; } + // ----------------------------------------------------------------------------------------------------------------- + // Query - Executes + /** * Queries all rows and returns them as an array of model instances. * @@ -68,9 +80,19 @@ public function wherePrimaryKeyMatches(Model $instance): ModelQuery public function queryAllModels(): array { $rows = $this->queryAllRows(); + // Wrap in batch loader for relationships (if any) to optimize queries $relationshipBatcher = new RelationshipBatcher($this->referenceModel, $rows); - return $relationshipBatcher->loadAllModels(); + $models = $relationshipBatcher->loadAllModels(); + + // Fire result hooks + if (!empty($this->resultHooks)) { + foreach ($models as $model) { + $this->fireResultHook($model); + } + } + + return $models; } /** @@ -81,20 +103,14 @@ public function queryAllModels(): array */ public function queryAllModelsIndexed(?string $indexKey = null): array { - $rows = $this->queryAllRows(); - $models = []; + $models = $this->queryAllModels(); + $indexed = []; - foreach ($rows as $row) { - /** - * @var Model $instance - */ - $instance = new $this->modelName($row); - - $models[$indexKey ? $instance->$indexKey : - $instance->getPrimaryKeyValue()] = $instance; + foreach ($models as $model) { + $indexed[$indexKey ? $model->$indexKey : $model->getPrimaryKeyValue()] = $model; } - return $models; + return $indexed; } /** @@ -110,9 +126,14 @@ public function querySingleModel(): ?Model return null; } - return new $this->modelName($row); + $model = new $this->modelName($row); + $this->fireResultHook($model); + return $model; } + // ----------------------------------------------------------------------------------------------------------------- + // Util + /** * @throws ModelAccessException */ @@ -142,4 +163,19 @@ public function createStatementText(): string $this->verifyAccess(); return parent::createStatementText(); } + + // ----------------------------------------------------------------------------------------------------------------- + // Result hooks + + public function addResultHook(callable $callback): void + { + $this->resultHooks[] = $callback; + } + + public function fireResultHook(Model $model): void + { + foreach ($this->resultHooks as $hook) { + $hook($model); + } + } } diff --git a/lib/Relationships/HasManyRelationship.php b/lib/Relationships/HasManyRelationship.php index cce35cd..e72f4e7 100644 --- a/lib/Relationships/HasManyRelationship.php +++ b/lib/Relationships/HasManyRelationship.php @@ -45,17 +45,25 @@ public function getPrimaryKeyValue(): mixed /** * Begins a query for this relationship, with a predefined WHERE clause for the foreign key. * + * @param bool $hookResults If true, the query results will be hooked into the relationship cache. * @return ModelQuery */ - public function query(): ModelQuery + public function query(bool $hookResults = true): ModelQuery { $pkVal = $this->getPrimaryKeyValue(); if (empty($pkVal)) throw new \InvalidArgumentException("Cannot query relationship without having primary key set"); - return $this->referenceModel::query() + $query = $this->referenceModel::query() ->where("{$this->foreignKeyColumn} = ?", $pkVal); + + $query->addResultHook(function (Model $model) { + $pkVal = $model->getPrimaryKeyValue(); + $this->loadedModels[$pkVal] = $model; + }); + + return $query; } /** @@ -90,6 +98,24 @@ public function all(): array return $this->loadedModels; } + /** + * Gets a single model in this relationship by its primary key. + * Retrieves from cache if already loaded. + * + * @param mixed $fkValue + * @return Model|null + */ + public function fetch(mixed $fkValue): ?Model + { + if (isset($this->loadedModels[$fkValue])) { + return $this->loadedModels[$fkValue]; + } + + return $this->query() + ->andWhere("{$this->foreignKeyColumn} = ?", $fkValue) + ->querySingleModel(); + } + // ----------------------------------------------------------------------------------------------------------------- // API - Utils diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index 2ef8e13..ae78e60 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -111,5 +111,13 @@ public function testHasManyRelationship() "Expected 2 planes to be loaded after reset(), addLoaded() and all()"); $this->assertSame($plane2, $partialMany->all()[$plane2->id], "Expected original, cached relationship to be returned from addLoaded() call"); + + // Has many relationship: result hook + cached fetch test + $hookedMany = $airline->planes(); + $hookedMany->reset(); + $hookedPlane1 = $hookedMany->query()->where('id = ?', $plane1->id)->querySingleModel(); + $fetchPlane1 = $hookedMany->fetch($plane1->id); + $this->assertSame($hookedPlane1, $fetchPlane1, + "Expected cached fetch() to return the same model instance as hooked query()->querySingleModel()"); } } \ No newline at end of file From 02eebb62f7f5fa549d167ae46798a040810d3b0d Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 01:59:33 +0100 Subject: [PATCH 09/11] docs: custom fk for hasmany --- .idea/dictionaries/Roy_de_Jong.xml | 1 + docs/Relationships.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.idea/dictionaries/Roy_de_Jong.xml b/.idea/dictionaries/Roy_de_Jong.xml index de6f82c..b2d2c58 100644 --- a/.idea/dictionaries/Roy_de_Jong.xml +++ b/.idea/dictionaries/Roy_de_Jong.xml @@ -2,6 +2,7 @@ instarecord + singularized unstage diff --git a/docs/Relationships.md b/docs/Relationships.md index 5e01d9e..72133bf 100644 --- a/docs/Relationships.md +++ b/docs/Relationships.md @@ -60,6 +60,8 @@ class User extends Model Rather than defining a property, you define a method that returns a `ManyRelationship` instance. This instance will be cached and reused for the lifetime of the model. +You can optionally specify a foreign key column name as the second parameter to `hasMany()`. Otherwise, it is automatically derived from the singularized host table name with an `_id` suffix (so, in this example, `user_id`). + ### Querying You can use the `query()` method on the relationship to get a query builder for the related model: From 05f746a638a114d00d475b8585dbda570ef5a0bd Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 02:01:44 +0100 Subject: [PATCH 10/11] fix: HasMany - hookResults param is ignored --- lib/Relationships/HasManyRelationship.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/Relationships/HasManyRelationship.php b/lib/Relationships/HasManyRelationship.php index e72f4e7..e182da2 100644 --- a/lib/Relationships/HasManyRelationship.php +++ b/lib/Relationships/HasManyRelationship.php @@ -58,10 +58,12 @@ public function query(bool $hookResults = true): ModelQuery $query = $this->referenceModel::query() ->where("{$this->foreignKeyColumn} = ?", $pkVal); - $query->addResultHook(function (Model $model) { - $pkVal = $model->getPrimaryKeyValue(); - $this->loadedModels[$pkVal] = $model; - }); + if ($hookResults) { + $query->addResultHook(function (Model $model) { + $pkVal = $model->getPrimaryKeyValue(); + $this->loadedModels[$pkVal] = $model; + }); + } return $query; } From a0eb0e5f2463a86d38ba76edc8df9ee4a3373681 Mon Sep 17 00:00:00 2001 From: Roy de Jong Date: Sat, 20 Jan 2024 02:08:24 +0100 Subject: [PATCH 11/11] fix: HasMany - fetch doesn't do what it should --- lib/Relationships/HasManyRelationship.php | 12 +++++++-- tests/Relationships/RelationshipTest.php | 31 +++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/Relationships/HasManyRelationship.php b/lib/Relationships/HasManyRelationship.php index e182da2..c18fff6 100644 --- a/lib/Relationships/HasManyRelationship.php +++ b/lib/Relationships/HasManyRelationship.php @@ -113,9 +113,17 @@ public function fetch(mixed $fkValue): ?Model return $this->loadedModels[$fkValue]; } - return $this->query() - ->andWhere("{$this->foreignKeyColumn} = ?", $fkValue) + $pkName = $this->referenceModel->getPrimaryKeyColumnName(); + + $result = $this->query() + ->andWhere("`{$pkName}` = ?", $fkValue) ->querySingleModel(); + + if ($result) { + $this->loadedModels[$fkValue] = $result; + } + + return $result; } // ----------------------------------------------------------------------------------------------------------------- diff --git a/tests/Relationships/RelationshipTest.php b/tests/Relationships/RelationshipTest.php index ae78e60..09e650d 100644 --- a/tests/Relationships/RelationshipTest.php +++ b/tests/Relationships/RelationshipTest.php @@ -112,12 +112,39 @@ public function testHasManyRelationship() $this->assertSame($plane2, $partialMany->all()[$plane2->id], "Expected original, cached relationship to be returned from addLoaded() call"); + // Has many relationship: uncached & cached fetch test + $partialMany->reset(); + $fetchPlane1 = $partialMany->fetch($plane1->id); + $fetchPlane2 = $partialMany->fetch($plane2->id); + $this->assertNotNull($fetchPlane1, "Expected fetch() to return plane 1"); + $this->assertNotNull($fetchPlane2, "Expected fetch() to return plane 2"); + $fetchPlane1b = $partialMany->fetch($plane1->id); + $fetchPlane2b = $partialMany->fetch($plane2->id); + $this->assertSame($fetchPlane1, $fetchPlane1b, + "Expected cached fetch() to return the same model instance as uncached fetch()"); + $this->assertSame($fetchPlane2, $fetchPlane2b, + "Expected cached fetch() to return the same model instance as uncached fetch()"); + // Has many relationship: result hook + cached fetch test $hookedMany = $airline->planes(); $hookedMany->reset(); - $hookedPlane1 = $hookedMany->query()->where('id = ?', $plane1->id)->querySingleModel(); + $hookedPlanes = $hookedMany->query()->queryAllModels(); + $hookedPlane1 = null; + $hookedPlane2 = null; + foreach ($hookedPlanes as $hookedPlane) { + if ($hookedPlane->id === $plane1->id) { + $hookedPlane1 = $hookedPlane; + } else if ($hookedPlane->id === $plane2->id) { + $hookedPlane2 = $hookedPlane; + } + } + $this->assertNotNull($hookedPlane1, "queryAllModels should have returned plane 1"); + $this->assertNotNull($hookedPlane2, "queryAllModels should have returned plane 2"); $fetchPlane1 = $hookedMany->fetch($plane1->id); $this->assertSame($hookedPlane1, $fetchPlane1, - "Expected cached fetch() to return the same model instance as hooked query()->querySingleModel()"); + "Expected cached fetch() to return the same model instance as hooked query()->queryAllModels()"); + $fetchPlane2 = $hookedMany->fetch($plane2->id); + $this->assertSame($hookedPlane2, $fetchPlane2, + "Expected cached fetch() to return the same model instance as hooked query()->queryAllModels()"); } } \ No newline at end of file