diff --git a/README.md b/README.md index 759a8aa..8ad1d6b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ final class AddressMapper extends RecordMapper */ final class UserMapper extends EntityRecordMapper { + /** @var LazyBuffer */ + private LazyBuffer $addressLoader; + public function __construct( private readonly AddressRepository $addressRepository, ) { @@ -96,18 +99,25 @@ final class UserMapper extends EntityRecordMapper $this->string('name', fn(User $entity) => $entity->name); $this->date('date_of_birth', fn(User $entity) => $entity->dateOfBirth); $this->integer('address_id', fn(User $entity) => $entity->address->id); + + $this->addressLoader = lazyBufferOf(Address::class, function (array $ids) { + return listOf($this->addressRepository->loadAllByIds($ids)) + ->indexedBy(fn(Address $address) => $address->id) + ->entries(); + }); } - public function map(array $row): User + #[Override] public function map(array $row): User { return new User( id: $row['id'], name: $row['name'], dateOfBirth: new DateTime($row['date_of_birth']), - address: lazyOf(Address::class, fn() => $this->addressRepository->loadById($row['address_id'])), + address: $this->addressLoader->lazyOf($row['address_id']), ); } } + ``` ### Nullable Support @@ -115,6 +125,62 @@ final class UserMapper extends EntityRecordMapper For nullable fields, use the `->nullable(...)` method on column definitions. This allows you to gracefully handle `NULL` values in your database rows. +### Foreign keys and objects + +The orm does not resolve foreign keys and objects automatically, +instead you have to inject the object repository and load as needed: + +```php +return new User( + ..., + /** lazy load foreign object */ + address: lazyOf(Address::class, fn() => $addressRepository->loadById($row['address_id'])), + /** eager load foreign object */ + address: $addressRepository->loadById($row['address_id']), +); +``` + +### Lazy loading + +Records can be lazy loaded with `lazyOf` function, which receives an initializer and loads the entry only when any of its properties are called: +```php +/** lazy object */ +$address = lazyOf( + /** The class to be loaded */ + Address::class, + /** The object initializer */ + fn() => $addressRepository->loadById($addressId), +); + +/** loaded object */ +$street = $address->street; +``` + +### Solving N+1 problems + +N+1 can be solved with `lazyBufferOf` function, it manages buffered and loaded records, +all buffered records are loaded at once when any of the entries are loaded, +and all loaded records are returned right away without additional loader calls + +```php +$addressLoader = lazyBufferOf( + /** The class to be loaded */ + Address::class, + /** The object list loader */ + function (array $bufferedIds) { + listOf($this->addressRepository->loadAllByIds($ids)) + ->indexedBy(fn(Address $address) => $address->id) + ->entries() + }, +); + +/** lazy object */ +$address = $addressLoader->lazyOf($addressId), + +/** loaded object */ +$street = $address->street; +``` + ## 🗃️ Repositories This library also provides base repository classes that you can extend to perform actual database operations. diff --git a/composer.lock b/composer.lock index dc35865..5a1b7cf 100644 --- a/composer.lock +++ b/composer.lock @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/tcds-io/php-better-generics.git", - "reference": "c93056f012a9b8538a1b6cb8351d863982e814a0" + "reference": "7f5726fe78108ff44a883adb0713de138259efc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/c93056f012a9b8538a1b6cb8351d863982e814a0", - "reference": "c93056f012a9b8538a1b6cb8351d863982e814a0", + "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/7f5726fe78108ff44a883adb0713de138259efc1", + "reference": "7f5726fe78108ff44a883adb0713de138259efc1", "shasum": "" }, "require": { @@ -26,7 +26,8 @@ "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", - "slevomat/coding-standard": "^8.15" + "slevomat/coding-standard": "^8.15", + "symfony/var-dumper": "^7.2" }, "default-branch": true, "type": "library", @@ -53,7 +54,7 @@ "issues": "https://github.com/tcds-io/php-better-generics/issues", "source": "https://github.com/tcds-io/php-better-generics/tree/main" }, - "time": "2025-05-12T19:14:59+00:00" + "time": "2025-05-14T11:14:28+00:00" } ], "packages-dev": [ @@ -1837,20 +1838,20 @@ }, { "name": "sanmai/pipeline", - "version": "6.12", + "version": "6.13", "source": { "type": "git", "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" + "reference": "b6d79d88d98680a823c38d63d4e8028254246cfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", + "url": "https://api.github.com/repos/sanmai/pipeline/zipball/b6d79d88d98680a823c38d63d4e8028254246cfd", + "reference": "b6d79d88d98680a823c38d63d4e8028254246cfd", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": ">=8.2" }, "require-dev": { "ergebnis/composer-normalize": "^2.8", @@ -1859,8 +1860,8 @@ "league/pipeline": "^0.3 || ^1.0", "phan/phan": ">=1.1", "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", + "phpstan/phpstan": ">=0.10 <2", + "phpunit/phpunit": ">=9.4 <12", "vimeo/psalm": ">=2" }, "type": "library", @@ -1890,7 +1891,7 @@ "description": "General-purpose collections pipeline", "support": { "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" + "source": "https://github.com/sanmai/pipeline/tree/6.13" }, "funding": [ { @@ -1898,7 +1899,7 @@ "type": "github" } ], - "time": "2024-10-17T02:22:57+00:00" + "time": "2025-05-13T23:25:07+00:00" }, { "name": "sebastian/cli-parser", @@ -3774,16 +3775,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.1.1", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "234f6fe34a0bead8c5ae1cfc0800539442e6f619" + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/234f6fe34a0bead8c5ae1cfc0800539442e6f619", - "reference": "234f6fe34a0bead8c5ae1cfc0800539442e6f619", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", "shasum": "" }, "require": { @@ -3893,7 +3894,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.1.1" + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" }, "funding": [ { @@ -3909,7 +3910,7 @@ "type": "github" } ], - "time": "2025-04-28T07:56:17+00:00" + "time": "2025-05-14T06:15:44+00:00" }, { "name": "theseer/tokenizer", diff --git a/tests/Fixtures/AddressRepository.php b/tests/Fixtures/AddressRepository.php index 1ccfb65..8213a42 100644 --- a/tests/Fixtures/AddressRepository.php +++ b/tests/Fixtures/AddressRepository.php @@ -8,6 +8,7 @@ use Tcds\Io\Orm\Connection\Connection; use Tcds\Io\Orm\RecordMapper; use Tcds\Io\Orm\RecordRepository; +use Traversable; /** * @extends RecordRepository
@@ -23,4 +24,13 @@ public function loadById(int $id): Address { return $this->selectOneWhere(['id' => $id]) ?? throw new Exception('Address not found'); } + + /** + * @param list $ids + * @return Traversable
+ */ + public function loadAllByIds(array $ids): Traversable + { + return $this->selectManyByQuery('SELECT * FROM addresses WHERE id IN (:ids)', ['ids' => $ids]); + } } diff --git a/tests/Fixtures/UserMapper.php b/tests/Fixtures/UserMapper.php index 56d94a8..f35c466 100644 --- a/tests/Fixtures/UserMapper.php +++ b/tests/Fixtures/UserMapper.php @@ -6,6 +6,7 @@ use DateTime; use Override; +use Tcds\Io\Generic\LazyBuffer; use Tcds\Io\Orm\EntityRecordMapper; /** @@ -13,6 +14,9 @@ */ final class UserMapper extends EntityRecordMapper { + /** @var LazyBuffer */ + private LazyBuffer $addressLoader; + public function __construct( private readonly AddressRepository $addressRepository, ) { @@ -21,6 +25,12 @@ public function __construct( $this->string('name', fn(User $entity) => $entity->name); $this->date('date_of_birth', fn(User $entity) => $entity->dateOfBirth); $this->integer('address_id', fn(User $entity) => $entity->address->id); + + $this->addressLoader = lazyBufferOf(Address::class, function (array $ids) { + return listOf($this->addressRepository->loadAllByIds($ids)) + ->indexedBy(fn(Address $address) => $address->id) + ->entries(); + }); } #[Override] public function map(array $row): User @@ -29,7 +39,7 @@ public function __construct( id: $row['id'], name: $row['name'], dateOfBirth: new DateTime($row['date_of_birth']), - address: lazyOf(Address::class, fn() => $this->addressRepository->loadById($row['address_id'])), + address: $this->addressLoader->lazyOf($row['address_id']), ); } } diff --git a/tests/Unit/EntityRecordMapperTest.php b/tests/Unit/EntityRecordMapperTest.php index bca33b2..e96b455 100644 --- a/tests/Unit/EntityRecordMapperTest.php +++ b/tests/Unit/EntityRecordMapperTest.php @@ -68,8 +68,8 @@ private function setupLoadAddress(int $userId, Address $address): void { $this->addressRepository ->expects($this->once()) - ->method('loadById') - ->with($userId) - ->willReturn($address); + ->method('loadAllByIds') + ->with([$userId]) + ->willReturn(listOf($address)); } }