├── .gitignore ├── tests └── Doctrine │ └── SkeletonMapper │ └── Tests │ ├── Model │ ├── Identifiable.php │ ├── GroupRepository.php │ ├── UserRepository.php │ ├── ProfileRepository.php │ ├── BaseObject.php │ ├── Group.php │ ├── Address.php │ ├── Profile.php │ └── User.php │ ├── ArrayImplementation │ ├── ObjectPersister.php │ └── ObjectDataRepository.php │ ├── DataTesterInterface.php │ ├── Collections │ └── LazyCollectionTest.php │ ├── Persister │ ├── ObjectPersisterFactoryTest.php │ └── ArrayObjectPersisterTest.php │ ├── ObjectFactoryTest.php │ ├── Event │ ├── PreLoadEventArgsTest.php │ └── PreUpdateEventArgsTest.php │ ├── ObjectRepository.php │ ├── UnitOfWork │ ├── ChangeSetTest.php │ └── ChangeSetsTest.php │ ├── ObjectRepository │ ├── ObjectRepositoryFactoryTest.php │ ├── BasicObjectRepositoryTest.php │ └── ObjectRepositoryTest.php │ ├── Hydrator │ └── BasicObjectHydratorTest.php │ ├── DataSource │ ├── CriteriaMatcherTest.php │ ├── DataSourceObjectDataRepositoryTest.php │ └── SorterTest.php │ ├── DataRepository │ ├── ArrayObjectDataRepositoryTest.php │ └── BasicObjectDataRepositoryTest.php │ ├── Functional │ └── ArrayImplementationTest.php │ └── Mapping │ └── ClassMetadataTest.php ├── lib └── Doctrine │ └── SkeletonMapper │ ├── DataSource │ ├── DataSource.php │ ├── CriteriaMatcher.php │ ├── Sorter.php │ └── DataSourceObjectDataRepository.php │ ├── Hydrator │ ├── ObjectHydrator.php │ ├── ObjectHydratorInterface.php │ ├── HydratableInterface.php │ └── BasicObjectHydrator.php │ ├── DataRepository │ ├── ObjectDataRepository.php │ ├── ObjectDataRepositoryInterface.php │ ├── BasicObjectDataRepository.php │ └── ArrayObjectDataRepository.php │ ├── Mapping │ ├── LoadMetadataInterface.php │ ├── ClassMetadataInstantiatorInterface.php │ ├── ClassMetadataInstantiator.php │ ├── ClassMetadataInterface.php │ ├── ClassMetadataFactory.php │ └── ClassMetadata.php │ ├── Persister │ ├── IdentifiableInterface.php │ ├── ObjectPersister.php │ ├── PersistableInterface.php │ ├── ObjectPersisterFactoryInterface.php │ ├── ObjectPersisterFactory.php │ ├── ObjectPersisterInterface.php │ ├── ArrayObjectPersister.php │ └── BasicObjectPersister.php │ ├── Event │ ├── OnFlushEventArgs.php │ ├── PreFlushEventArgs.php │ ├── PostFlushEventArgs.php │ ├── OnClearEventArgs.php │ ├── LifecycleEventArgs.php │ ├── PreLoadEventArgs.php │ └── PreUpdateEventArgs.php │ ├── ObjectRepository │ ├── ObjectRepositoryFactoryInterface.php │ ├── BasicObjectRepository.php │ ├── ObjectRepositoryInterface.php │ ├── ObjectRepositoryFactory.php │ └── ObjectRepository.php │ ├── ObjectFactory.php │ ├── UnitOfWork │ ├── Change.php │ ├── ChangeSets.php │ ├── ChangeSet.php │ ├── Persister.php │ └── EventDispatcher.php │ ├── Collections │ └── LazyCollection.php │ ├── ObjectManagerInterface.php │ ├── ObjectIdentityMap.php │ ├── Events.php │ ├── ObjectManager.php │ └── UnitOfWork.php ├── .github └── workflows │ ├── coding-standards.yml │ ├── continuous-integration.yml │ ├── static-analysis.yml │ └── release-on-milestone-closed.yml ├── .doctrine-project.json ├── phpunit.xml.dist ├── phpcs.xml.dist ├── LICENSE ├── README.md ├── phpstan.neon.dist ├── composer.json └── docs └── en └── index.rst /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpcs-cache 2 | /.phpunit.result.cache 3 | /composer.lock 4 | /vendor 5 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/Identifiable.php: -------------------------------------------------------------------------------- 1 | $metadata */ 10 | public static function loadMetadata(ClassMetadataInterface $metadata): void; 11 | } 12 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Hydrator/ObjectHydratorInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class ObjectPersister implements ObjectPersisterInterface 14 | { 15 | } 16 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataTesterInterface.php: -------------------------------------------------------------------------------- 1 | $className 12 | * 13 | * @return ClassMetadata 14 | */ 15 | public function instantiate(string $className): ClassMetadata; 16 | } 17 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Hydrator/HydratableInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class OnFlushEventArgs extends ManagerEventArgs 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Mapping/ClassMetadataInstantiator.php: -------------------------------------------------------------------------------- 1 | */ 8 | class ClassMetadataInstantiator implements ClassMetadataInstantiatorInterface 9 | { 10 | public function instantiate(string $className): ClassMetadata 11 | { 12 | return new ClassMetadata($className); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Event/PreFlushEventArgs.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class PreFlushEventArgs extends ManagerEventArgs 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*.x" 7 | - "master" 8 | push: 9 | branches: 10 | - "*.x" 11 | - "master" 12 | 13 | env: 14 | fail-fast: true 15 | 16 | jobs: 17 | phpunit: 18 | name: "PHPUnit" 19 | uses: "doctrine/.github/.github/workflows/continuous-integration.yml@3.0.0" 20 | with: 21 | php-versions: '["8.0", "8.1", "8.2", "8.3"]' 22 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Event/PostFlushEventArgs.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class PostFlushEventArgs extends ManagerEventArgs 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Event/OnClearEventArgs.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class OnClearEventArgs extends BaseOnClearEventArgs 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/PersistableInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class LifecycleEventArgs extends BaseLifecycleEventArgs 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectRepository/ObjectRepositoryFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $className 14 | * 15 | * @return ObjectRepositoryInterface 16 | * 17 | * @template T of object 18 | */ 19 | public function getRepository(string $className): ObjectRepositoryInterface; 20 | } 21 | -------------------------------------------------------------------------------- /.doctrine-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "active": true, 3 | "name": "Skeleton Mapper", 4 | "slug": "skeleton-mapper", 5 | "docsSlug": "doctrine-skeleton-mapper", 6 | "versions": [ 7 | { 8 | "name": "2.0", 9 | "branchName": "2.0.x", 10 | "slug": "2.0", 11 | "current": true, 12 | "aliases": [ 13 | "current", 14 | "stable" 15 | ] 16 | }, 17 | { 18 | "name": "1.0", 19 | "branchName": "1.0.x", 20 | "slug": "1.0", 21 | "maintained": false 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/ObjectPersisterFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getPersister(string $className): ObjectPersisterInterface; 20 | 21 | /** @phpstan-return array> */ 22 | public function getPersisters(): array; 23 | } 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | ./lib 12 | 13 | 14 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectFactory.php: -------------------------------------------------------------------------------- 1 | instantiator = new Instantiator(); 20 | } 21 | 22 | /** @phpstan-param class-string $className */ 23 | public function create(string $className): object 24 | { 25 | return $this->instantiator->instantiate($className); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork/Change.php: -------------------------------------------------------------------------------- 1 | propertyName; 16 | } 17 | 18 | public function getOldValue(): mixed 19 | { 20 | return $this->oldValue; 21 | } 22 | 23 | public function getNewValue(): mixed 24 | { 25 | return $this->newValue; 26 | } 27 | 28 | public function setNewValue(mixed $newValue): void 29 | { 30 | $this->newValue = $newValue; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork/ChangeSets.php: -------------------------------------------------------------------------------- 1 | getObjectChangeSet($object)->addChange($change); 17 | } 18 | 19 | public function getObjectChangeSet(object $object): ChangeSet 20 | { 21 | $oid = spl_object_hash($object); 22 | 23 | if (! isset($this->changeSets[$oid])) { 24 | $this->changeSets[$oid] = new ChangeSet($object); 25 | } 26 | 27 | return $this->changeSets[$oid]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Collections/LazyCollectionTest.php: -------------------------------------------------------------------------------- 1 | $collection */ 18 | $collection = new LazyCollection(static function () use ($wrappedCollection): ArrayCollection { 19 | return $wrappedCollection; 20 | }); 21 | 22 | self::assertSame($wrappedCollection, $collection->getCollection()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | 2 | name: "Static Analysis" 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - "*.x" 8 | - "master" 9 | push: 10 | branches: 11 | - "*.x" 12 | - "master" 13 | 14 | jobs: 15 | static-analysis-phpstan: 16 | name: "Static Analysis with PHPStan" 17 | runs-on: "ubuntu-latest" 18 | 19 | steps: 20 | - uses: "actions/checkout@v3" 21 | 22 | - name: "Setup PHP Action" 23 | uses: "shivammathur/setup-php@v2" 24 | with: 25 | php-version: "8.0" 26 | 27 | - name: "Install dependencies with Composer" 28 | uses: "ramsey/composer-install@v2" 29 | with: 30 | composer-options: "--prefer-dist --no-progress --no-suggest" 31 | 32 | - name: "Run PHPStan" 33 | run: "./vendor/bin/phpstan analyse" 34 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Event/PreLoadEventArgs.php: -------------------------------------------------------------------------------- 1 | data = &$data; 23 | } 24 | 25 | /** 26 | * Get the array of data to be loaded and hydrated. 27 | * 28 | * @return mixed[] 29 | */ 30 | public function &getData(): array 31 | { 32 | return $this->data; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork/ChangeSet.php: -------------------------------------------------------------------------------- 1 | object; 17 | } 18 | 19 | public function addChange(Change $change): void 20 | { 21 | $this->changes[$change->getPropertyName()] = $change; 22 | } 23 | 24 | /** @return Change[] */ 25 | public function getChanges(): array 26 | { 27 | return $this->changes; 28 | } 29 | 30 | public function hasChangedField(string $fieldName): bool 31 | { 32 | return isset($this->changes[$fieldName]); 33 | } 34 | 35 | public function getFieldChange(string $fieldName): Change|null 36 | { 37 | return $this->changes[$fieldName] ?? null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Persister/ObjectPersisterFactoryTest.php: -------------------------------------------------------------------------------- 1 | createMock(ObjectPersisterInterface::class); 18 | 19 | $this->factory->addObjectPersister('TestClassName', $persister); 20 | 21 | self::assertSame($persister, $this->factory->getPersister('TestClassName')); 22 | self::assertSame(['TestClassName' => $persister], $this->factory->getPersisters()); 23 | } 24 | 25 | protected function setUp(): void 26 | { 27 | $this->factory = new ObjectPersisterFactory(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/ObjectFactoryTest.php: -------------------------------------------------------------------------------- 1 | create(stdClass::class); 18 | self::assertInstanceOf(stdClass::class, $object); 19 | } 20 | 21 | public function testCreateWithReflectionMethod(): void 22 | { 23 | $factory = new ObjectFactoryReflectionMethodStub(); 24 | $object = $factory->create(stdClass::class); 25 | self::assertInstanceOf(stdClass::class, $object); 26 | } 27 | } 28 | 29 | class ObjectFactoryStub extends ObjectFactory 30 | { 31 | } 32 | 33 | class ObjectFactoryReflectionMethodStub extends ObjectFactory 34 | { 35 | protected function isReflectionMethodAvailable(): bool 36 | { 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Hydrator/BasicObjectHydrator.php: -------------------------------------------------------------------------------- 1 | hydrate($data, $this->objectManager); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | lib 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | */tests/* 23 | 24 | 25 | 26 | */tests/* 27 | 28 | 29 | 30 | lib/Doctrine/SkeletonMapper/Events.php 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2018 Doctrine Project 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Doctrine SkeletonMapper 2 | ======================= 3 | 4 | [![Continuous Integration](https://github.com/doctrine/skeleton-mapper/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/doctrine/skeleton-mapper/actions/workflows/continuous-integration.yml) 5 | [![Coding Standards](https://github.com/doctrine/skeleton-mapper/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/doctrine/skeleton-mapper/actions/workflows/coding-standards.yml) 6 | [![Static Analysis](https://github.com/doctrine/skeleton-mapper/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/doctrine/skeleton-mapper/actions/workflows/static-analysis.yml) 7 | [![Code Coverage](https://codecov.io/gh/doctrine/skeleton-mapper/branch/1.0.x/graph/badge.svg)](https://codecov.io/gh/doctrine/skeleton-mapper/branch/1.0.x) 8 | [![Total Downloads](https://poser.pugx.org/doctrine/skeleton-mapper/downloads.png)](https://packagist.org/packages/doctrine/skeleton-mapper) 9 | 10 | ## Documentation 11 | 12 | All available documentation can be found [here](https://www.doctrine-project.org/projects/skeleton-mapper.html). 13 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Collections/LazyCollection.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class LazyCollection extends AbstractLazyCollection 18 | { 19 | /** @var callable|null */ 20 | private $callback; 21 | 22 | public function __construct(callable $callback) 23 | { 24 | $this->callback = $callback; 25 | } 26 | 27 | /** @return Collection */ 28 | public function getCollection(): Collection 29 | { 30 | $this->initialize(); 31 | 32 | return $this->collection; 33 | } 34 | 35 | protected function doInitialize(): void 36 | { 37 | if ($this->callback === null) { 38 | return; 39 | } 40 | 41 | $this->collection = call_user_func($this->callback); 42 | $this->callback = null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Event/PreLoadEventArgsTest.php: -------------------------------------------------------------------------------- 1 | event->getData(); 26 | 27 | $data['test'] = true; 28 | 29 | self::assertEquals($data, $this->event->getData()); 30 | } 31 | 32 | protected function setUp(): void 33 | { 34 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 35 | 36 | $this->object = new stdClass(); 37 | $this->data = []; 38 | 39 | $this->event = new PreLoadEventArgs($this->object, $this->objectManager, $this->data); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectRepository/BasicObjectRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class BasicObjectRepository extends ObjectRepository 14 | { 15 | /** @return mixed[] */ 16 | public function getObjectIdentifier(object $object): array 17 | { 18 | return $this->objectManager 19 | ->getClassMetadata($object::class) 20 | ->getIdentifierValues($object); 21 | } 22 | 23 | /** 24 | * @param mixed[] $data 25 | * 26 | * @return mixed[] 27 | */ 28 | public function getObjectIdentifierFromData(array $data): array 29 | { 30 | $identifier = []; 31 | 32 | foreach ($this->class->getIdentifier() as $name) { 33 | $identifier[$name] = $data[$name]; 34 | } 35 | 36 | return $identifier; 37 | } 38 | 39 | public function merge(object $object): void 40 | { 41 | throw new BadMethodCallException('Not implemented.'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Mapping/ClassMetadataInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | interface ClassMetadataInterface extends ClassMetadata 16 | { 17 | /** @param mixed[] $identifier */ 18 | public function setIdentifier(array $identifier): void; 19 | 20 | /** @param string[] $identifierFieldNames */ 21 | public function setIdentifierFieldNames(array $identifierFieldNames): void; 22 | 23 | /** @param mixed[] $mapping */ 24 | public function mapField(array $mapping): void; 25 | 26 | /** @return mixed[][] */ 27 | public function getFieldMappings(): array; 28 | 29 | public function hasLifecycleCallbacks(string $eventName): bool; 30 | 31 | /** @param mixed[]|null $arguments */ 32 | public function invokeLifecycleCallbacks(string $event, object $object, array|null $arguments = null): void; 33 | 34 | public function addLifecycleCallback(string $callback, string $event): void; 35 | } 36 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/ObjectRepository.php: -------------------------------------------------------------------------------- 1 | $object->getId()]; 21 | } 22 | 23 | /** 24 | * @param mixed[] $data 25 | * 26 | * @return mixed[] 27 | */ 28 | public function getObjectIdentifierFromData(array $data): array 29 | { 30 | return ['_id' => $data['_id']]; 31 | } 32 | 33 | public function merge(object $object): void 34 | { 35 | assert($object instanceof User); 36 | 37 | $user = $this->find($object->getId()); 38 | assert($user instanceof User); 39 | 40 | $user->setUsername($object->getUsername()); 41 | $user->setPassword($object->getPassword()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectRepository/ObjectRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | interface ObjectRepositoryInterface extends BaseObjectRepositoryInterface 16 | { 17 | /** 18 | * Returns the objects identifier. 19 | * 20 | * @return mixed[] 21 | */ 22 | public function getObjectIdentifier(object $object): array; 23 | 24 | /** 25 | * Returns the identifier. 26 | * 27 | * @param mixed[] $data 28 | * 29 | * @return mixed[] 30 | */ 31 | public function getObjectIdentifierFromData(array $data): array; 32 | 33 | public function merge(object $object): void; 34 | 35 | /** @param mixed[] $data */ 36 | public function hydrate(object $object, array $data): void; 37 | 38 | /** @phpstan-param class-string $className */ 39 | public function create(string $className): object; 40 | 41 | public function refresh(object $object): void; 42 | } 43 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/BaseObject.php: -------------------------------------------------------------------------------- 1 | listeners[] = $listener; 22 | } 23 | 24 | protected function onPropertyChanged(string $propName, mixed $oldValue, mixed $newValue): void 25 | { 26 | if ($this->listeners === []) { 27 | return; 28 | } 29 | 30 | foreach ($this->listeners as $listener) { 31 | $listener->propertyChanged($this, $propName, $oldValue, $newValue); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/UnitOfWork/ChangeSetTest.php: -------------------------------------------------------------------------------- 1 | getObject()); 21 | 22 | self::assertFalse($changeSet->hasChangedField('username')); 23 | 24 | $change = new Change('username', 'jwage', 'jonwage'); 25 | 26 | $changeSet->addChange($change); 27 | 28 | self::assertTrue($changeSet->hasChangedField('username')); 29 | 30 | self::assertSame($change, $changeSet->getFieldChange('username')); 31 | 32 | self::assertEquals('username', $change->getPropertyName()); 33 | self::assertEquals('jwage', $change->getOldValue()); 34 | self::assertEquals('jonwage', $change->getNewValue()); 35 | 36 | $change->setNewValue('jon'); 37 | 38 | self::assertEquals('jon', $change->getNewValue()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/ObjectRepository/ObjectRepositoryFactoryTest.php: -------------------------------------------------------------------------------- 1 | createMock(ObjectRepositoryInterface::class); 20 | 21 | $this->factory->addObjectRepository('TestClassName', $repository); 22 | 23 | self::assertSame($repository, $this->factory->getRepository('TestClassName')); 24 | } 25 | 26 | public function testGetRepositoryThrowsInvalidArgumentException(): void 27 | { 28 | $this->expectException(InvalidArgumentException::class); 29 | $this->expectExceptionMessage('ObjectRepository with class name DoesNotExist was not found'); 30 | 31 | $this->factory->getRepository('DoesNotExist'); 32 | } 33 | 34 | protected function setUp(): void 35 | { 36 | $this->factory = new ObjectRepositoryFactory(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Hydrator/BasicObjectHydratorTest.php: -------------------------------------------------------------------------------- 1 | hydrator->hydrate($object, $data); 25 | 26 | self::assertEquals($data, $object->data); 27 | } 28 | 29 | protected function setUp(): void 30 | { 31 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 32 | 33 | $this->hydrator = new BasicObjectHydrator($this->objectManager); 34 | } 35 | } 36 | 37 | class HydratableObject implements HydratableInterface 38 | { 39 | /** @var mixed[] */ 40 | public array $data; 41 | 42 | /** @param mixed[] $data */ 43 | public function hydrate(array $data, ObjectManagerInterface $objectManager): void 44 | { 45 | $this->data = $data; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectManagerInterface.php: -------------------------------------------------------------------------------- 1 | $className 21 | * 22 | * @psalm-return T|null 23 | * 24 | * @template T of object 25 | */ 26 | public function getOrCreateObject(string $className, array $data): object|null; 27 | 28 | public function getUnitOfWork(): UnitOfWork; 29 | 30 | /** 31 | * @psalm-param class-string $className 32 | * 33 | * @return ObjectRepositoryInterface 34 | */ 35 | public function getRepository(string $className): ObjectRepositoryInterface; 36 | 37 | /** 38 | * @param class-string $className 39 | * 40 | * @phpstan-return ClassMetadataInterface 41 | * 42 | * @template T of object 43 | */ 44 | public function getClassMetadata(string $className): ClassMetadataInterface; 45 | } 46 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/UnitOfWork/ChangeSetsTest.php: -------------------------------------------------------------------------------- 1 | addObjectChange($object, $change); 23 | 24 | $changeSet = new ChangeSet($object, ['username' => $change]); 25 | 26 | self::assertEquals($changeSet, $changeSets->getObjectChangeSet($object)); 27 | } 28 | 29 | public function testGetObjectChangeSet(): void 30 | { 31 | $object = new stdClass(); 32 | $change = new Change('username', 'jonwage', 'jwage'); 33 | $changeSets = new ChangeSets(); 34 | $changeSets->addObjectChange($object, $change); 35 | 36 | $changeSet = $changeSets->getObjectChangeSet($object); 37 | 38 | self::assertCount(1, $changeSet->getChanges()); 39 | self::assertSame($change, $changeSet->getChanges()['username']); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-phpunit/extension.neon 3 | - vendor/phpstan/phpstan-strict-rules/rules.neon 4 | 5 | parameters: 6 | level: 8 7 | paths: 8 | - lib 9 | ignoreErrors: 10 | - 11 | message: "#Method Doctrine\\\\SkeletonMapper\\\\ObjectManager::getClassMetadata\\(\\) should return Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadataInterface\\ but returns Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata\\#" 12 | path: lib/Doctrine/SkeletonMapper/ObjectManager.php 13 | - 14 | message: "#Property Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadataFactory\\::\\$classes \\(array\\\\) does not accept array\\|T of Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadata\\>#" 15 | path: lib/Doctrine/SkeletonMapper/Mapping/ClassMetadataFactory.php 16 | - 17 | message: "#Method Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadataFactory::getMetadataFor\\(\\) should return T of Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadata but returns Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadata\\|T of Doctrine\\\\SkeletonMapper\\\\Mapping\\\\ClassMetadata#" 18 | path: lib/Doctrine/SkeletonMapper/Mapping/ClassMetadataFactory.php 19 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectRepository/ObjectRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | > */ 19 | private array $repositories = []; 20 | 21 | /** 22 | * @phpstan-param class-string $className 23 | * @phpstan-param ObjectRepositoryInterface $objectRepository 24 | */ 25 | public function addObjectRepository(string $className, ObjectRepositoryInterface $objectRepository): void 26 | { 27 | $this->repositories[$className] = $objectRepository; 28 | } 29 | 30 | /** 31 | * @phpstan-param class-string $className 32 | * 33 | * @phpstan-return ObjectRepositoryInterface 34 | */ 35 | public function getRepository(string $className): ObjectRepositoryInterface 36 | { 37 | if (! isset($this->repositories[$className])) { 38 | throw new InvalidArgumentException( 39 | sprintf('ObjectRepository with class name %s was not found', $className), 40 | ); 41 | } 42 | 43 | return $this->repositories[$className]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/ObjectPersisterFactory.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class ObjectPersisterFactory implements ObjectPersisterFactoryInterface 18 | { 19 | /** @phpstan-var array> */ 20 | private $persisters = []; 21 | 22 | /** 23 | * @phpstan-param class-string $className 24 | * @phpstan-param ObjectPersisterInterface $objectPersister 25 | */ 26 | public function addObjectPersister(string $className, ObjectPersisterInterface $objectPersister): void 27 | { 28 | $this->persisters[$className] = $objectPersister; 29 | } 30 | 31 | public function getPersister(string $className): ObjectPersisterInterface 32 | { 33 | if (! isset($this->persisters[$className])) { 34 | throw new InvalidArgumentException(sprintf('ObjectPersister with class name %s was not found', $className)); 35 | } 36 | 37 | return $this->persisters[$className]; 38 | } 39 | 40 | /** @return array> */ 41 | public function getPersisters(): array 42 | { 43 | return $this->persisters; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doctrine/skeleton-mapper", 3 | "type": "library", 4 | "description": "The Doctrine SkeletonMapper is a skeleton object mapper where you are 100% responsible for implementing the guts of the persistence operations. This means you write plain old PHP code for the data repositories, object repositories, object hydrators and object persisters.", 5 | "keywords": ["database", "persistence", "object"], 6 | "homepage": "https://www.doctrine-project.org/projects/skeleton-mapper.html", 7 | "license": "MIT", 8 | "authors": [ 9 | { "name": "Jonathan H. Wage", "email": "jonwage@gmail.com" } 10 | ], 11 | "require": { 12 | "php": "^8.0", 13 | "doctrine/collections": "^1.8|^2.2", 14 | "doctrine/instantiator": "^1.5|^2.0", 15 | "doctrine/persistence": "^3.1" 16 | }, 17 | "require-dev": { 18 | "doctrine/coding-standard": "^12.0", 19 | "phpstan/phpstan": "^1.11", 20 | "phpstan/phpstan-deprecation-rules": "^1.2", 21 | "phpstan/phpstan-phpunit": "^1.4", 22 | "phpstan/phpstan-strict-rules": "^1.6", 23 | "phpunit/phpunit": "~9.6" 24 | }, 25 | "config": { 26 | "sort-packages": true, 27 | "allow-plugins": { 28 | "dealerdirect/phpcodesniffer-composer-installer": true 29 | } 30 | }, 31 | "autoload": { 32 | "psr-0": { 33 | "Doctrine\\SkeletonMapper": "lib/", 34 | "Doctrine\\SkeletonMapper\\Tests": "tests/" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataSource/CriteriaMatcherTest.php: -------------------------------------------------------------------------------- 1 | 'jwage'], 16 | ['username' => 'jwage'], 17 | ); 18 | 19 | self::assertTrue($criteriaMatcher->matches()); 20 | } 21 | 22 | public function testEqualsFalse(): void 23 | { 24 | $criteriaMatcher = new CriteriaMatcher( 25 | ['username' => 'jwage'], 26 | ['username' => 'jonwage'], 27 | ); 28 | 29 | self::assertFalse($criteriaMatcher->matches()); 30 | } 31 | 32 | public function testContainsTrue(): void 33 | { 34 | $criteriaMatcher = new CriteriaMatcher( 35 | ['projects' => ['$contains' => 'dbal']], 36 | ['projects' => ['orm', 'dbal']], 37 | ); 38 | 39 | self::assertTrue($criteriaMatcher->matches()); 40 | } 41 | 42 | public function testContainsFalse(): void 43 | { 44 | $criteriaMatcher = new CriteriaMatcher( 45 | ['projects' => ['$contains' => 'mongodb-odm']], 46 | ['projects' => ['orm', 'dbal']], 47 | ); 48 | 49 | self::assertFalse($criteriaMatcher->matches()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/DataSource/CriteriaMatcher.php: -------------------------------------------------------------------------------- 1 | criteria as $key => $value) { 24 | if ($this->criteriaElementMatches($key, $value)) { 25 | continue; 26 | } 27 | 28 | $matches = false; 29 | } 30 | 31 | return $matches; 32 | } 33 | 34 | private function criteriaElementMatches(string $key, mixed $value): bool 35 | { 36 | if (isset($value['$contains'])) { 37 | if ($this->contains($key, $value)) { 38 | return true; 39 | } 40 | } elseif ($this->equals($key, $value)) { 41 | return true; 42 | } 43 | 44 | return false; 45 | } 46 | 47 | /** @param mixed[] $value */ 48 | private function contains(string $key, array $value): bool 49 | { 50 | return isset($this->row[$key]) && in_array($value['$contains'], $this->row[$key], true); 51 | } 52 | 53 | private function equals(string $key, mixed $value): bool 54 | { 55 | return isset($this->row[$key]) && $this->row[$key] === $value; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/ObjectPersisterInterface.php: -------------------------------------------------------------------------------- 1 | className; 25 | } 26 | 27 | /** @param class-string $className */ 28 | public function setClassName(string $className): void 29 | { 30 | $this->className = $className; 31 | } 32 | 33 | /** @return mixed[] */ 34 | public function find(mixed $id): array|null 35 | { 36 | $identifier = $this->getIdentifier(); 37 | 38 | $identifierValues = is_array($id) ? $id : [$id]; 39 | 40 | try { 41 | $criteria = array_combine($identifier, $identifierValues); 42 | } catch (ValueError) { 43 | throw new RuntimeException('array_combine failed. Make sure you passed a value for each identifier.'); 44 | } 45 | 46 | return $this->findOneBy($criteria); 47 | } 48 | 49 | /** @return mixed[] */ 50 | protected function getIdentifier(): array 51 | { 52 | return $this->objectManager 53 | ->getClassMetadata($this->getClassName()) 54 | ->getIdentifier(); 55 | } 56 | 57 | /** @return mixed[] */ 58 | protected function getObjectIdentifier(object $object): array 59 | { 60 | return $this->objectManager 61 | ->getRepository($this->getClassName()) 62 | ->getObjectIdentifier($object); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataRepository/ArrayObjectDataRepositoryTest.php: -------------------------------------------------------------------------------- 1 | */ 20 | private ArrayCollection $objects; 21 | 22 | private ArrayObjectDataRepository $objectDataRepository; 23 | 24 | public function testFindAll(): void 25 | { 26 | self::assertSame([['username' => 'jwage']], $this->objectDataRepository->findAll()); 27 | } 28 | 29 | public function testFindBy(): void 30 | { 31 | $criteria = ['username' => 'jwage']; 32 | $orderBy = ['username' => 'desc']; 33 | $limit = 20; 34 | $offset = 20; 35 | 36 | self::assertSame( 37 | [['username' => 'jwage']], 38 | $this->objectDataRepository->findBy($criteria, $orderBy, $limit, $offset), 39 | ); 40 | } 41 | 42 | public function testFindOneBy(): void 43 | { 44 | $criteria = ['username' => 'jwage']; 45 | 46 | self::assertSame( 47 | ['username' => 'jwage'], 48 | $this->objectDataRepository->findOneBy($criteria), 49 | ); 50 | } 51 | 52 | protected function setUp(): void 53 | { 54 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 55 | 56 | $this->objects = new ArrayCollection([ 57 | ['username' => 'jwage'], 58 | ]); 59 | 60 | $this->objectDataRepository = new ArrayObjectDataRepository( 61 | $this->objectManager, 62 | $this->objects, 63 | ArrayObject::class, 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork/Persister.php: -------------------------------------------------------------------------------- 1 | unitOfWork->getObjectsToPersist() as $object) { 23 | $persister = $this->unitOfWork->getObjectPersister($object); 24 | $repository = $this->unitOfWork->getObjectRepository($object); 25 | 26 | $objectData = $persister->persistObject($object); 27 | 28 | $identifier = $repository->getObjectIdentifierFromData($objectData); 29 | $persister->assignIdentifier($object, $identifier); 30 | $this->objectIdentityMap->addToIdentityMap($object, $objectData); 31 | 32 | $this->eventDispatcher->dispatchLifecycleEvent(Events::postPersist, $object); 33 | } 34 | } 35 | 36 | public function executeUpdates(): void 37 | { 38 | foreach ($this->unitOfWork->getObjectsToUpdate() as $object) { 39 | $changeSet = $this->unitOfWork->getObjectChangeSet($object); 40 | 41 | $this->unitOfWork->getObjectPersister($object) 42 | ->updateObject($object, $changeSet); 43 | 44 | $this->eventDispatcher->dispatchLifecycleEvent(Events::postUpdate, $object); 45 | } 46 | } 47 | 48 | public function executeRemoves(): void 49 | { 50 | foreach ($this->unitOfWork->getObjectsToRemove() as $object) { 51 | $this->unitOfWork->getObjectPersister($object) 52 | ->removeObject($object); 53 | 54 | $this->objectIdentityMap->detach($object); 55 | 56 | $this->eventDispatcher->dispatchLifecycleEvent(Events::postRemove, $object); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Mapping/ClassMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ClassMetadataFactory implements BaseClassMetadataFactory 16 | { 17 | /** @phpstan-var ClassMetadataInstantiatorInterface */ 18 | private $classMetadataInstantiator; 19 | 20 | /** 21 | * @var ClassMetadata[] 22 | * @psalm-var T[] 23 | */ 24 | private array $classes = []; 25 | 26 | /** @phpstan-param ClassMetadataInstantiatorInterface $classMetadataInstantiator */ 27 | public function __construct(ClassMetadataInstantiatorInterface $classMetadataInstantiator) 28 | { 29 | $this->classMetadataInstantiator = $classMetadataInstantiator; 30 | } 31 | 32 | /** 33 | * {@inheritDoc} 34 | */ 35 | public function getAllMetadata(): array 36 | { 37 | return $this->classes; 38 | } 39 | 40 | /** 41 | * {@inheritDoc} 42 | */ 43 | public function getMetadataFor($className): ClassMetadata 44 | { 45 | if (! isset($this->classes[$className])) { 46 | $metadata = $this->classMetadataInstantiator->instantiate($className); 47 | 48 | if ($metadata->reflClass->implementsInterface('Doctrine\SkeletonMapper\Mapping\LoadMetadataInterface')) { 49 | $className::loadMetadata($metadata); 50 | } 51 | 52 | $this->classes[$className] = $metadata; 53 | } 54 | 55 | return $this->classes[$className]; 56 | } 57 | 58 | /** 59 | * {@inheritDoc} 60 | */ 61 | public function hasMetadataFor($className): bool 62 | { 63 | return isset($this->classes[$className]); 64 | } 65 | 66 | /** 67 | * {@inheritDoc} 68 | */ 69 | public function setMetadataFor($className, $class) 70 | { 71 | $this->classes[$className] = $class; 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | */ 77 | public function isTransient($className): bool 78 | { 79 | return isset($this->classes[$className]); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Event/PreUpdateEventArgs.php: -------------------------------------------------------------------------------- 1 | objectChangeSet = $changeSet; 26 | } 27 | 28 | /** 29 | * Retrieves the object changeset. 30 | */ 31 | public function getObjectChangeSet(): ChangeSet 32 | { 33 | return $this->objectChangeSet; 34 | } 35 | 36 | /** 37 | * Checks if field has a changeset. 38 | */ 39 | public function hasChangedField(string $field): bool 40 | { 41 | return $this->objectChangeSet->hasChangedField($field); 42 | } 43 | 44 | /** 45 | * Gets the old value of the changeset of the changed field. 46 | */ 47 | public function getOldValue(string $field): mixed 48 | { 49 | $change = $this->objectChangeSet->getFieldChange($field); 50 | 51 | if ($change !== null) { 52 | return $change->getOldValue(); 53 | } 54 | 55 | return null; 56 | } 57 | 58 | /** 59 | * Gets the new value of the changeset of the changed field. 60 | */ 61 | public function getNewValue(string $field): mixed 62 | { 63 | $change = $this->objectChangeSet->getFieldChange($field); 64 | 65 | if ($change !== null) { 66 | return $change->getNewValue(); 67 | } 68 | 69 | return null; 70 | } 71 | 72 | /** 73 | * Sets the new value of this field. 74 | */ 75 | public function setNewValue(string $field, mixed $value): void 76 | { 77 | $change = $this->objectChangeSet->getFieldChange($field); 78 | 79 | if ($change !== null) { 80 | $change->setNewValue($value); 81 | } else { 82 | $this->objectChangeSet->addChange(new Change($field, null, $value)); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/DataRepository/ArrayObjectDataRepository.php: -------------------------------------------------------------------------------- 1 | $objects 14 | * @param class-string $className 15 | */ 16 | public function __construct( 17 | ObjectManagerInterface $objectManager, 18 | private ArrayCollection $objects, 19 | string $className, 20 | ) { 21 | parent::__construct($objectManager, $className); 22 | } 23 | 24 | /** @return mixed[][] */ 25 | public function findAll(): array 26 | { 27 | return $this->objects->toArray(); 28 | } 29 | 30 | /** 31 | * @param mixed[] $criteria 32 | * @param mixed[] $orderBy 33 | * 34 | * @return mixed[][] 35 | */ 36 | public function findBy( 37 | array $criteria, 38 | array|null $orderBy = null, 39 | int|null $limit = null, 40 | int|null $offset = null, 41 | ): array { 42 | $objects = []; 43 | 44 | foreach ($this->objects as $object) { 45 | $matches = true; 46 | 47 | foreach ($criteria as $key => $value) { 48 | if ($object[$key] === $value) { 49 | continue; 50 | } 51 | 52 | $matches = false; 53 | } 54 | 55 | if (! $matches) { 56 | continue; 57 | } 58 | 59 | $objects[] = $object; 60 | } 61 | 62 | return $objects; 63 | } 64 | 65 | /** 66 | * @param mixed[] $criteria 67 | * 68 | * @return mixed[]|null 69 | */ 70 | public function findOneBy(array $criteria): array|null 71 | { 72 | foreach ($this->objects as $object) { 73 | $matches = true; 74 | 75 | foreach ($criteria as $key => $value) { 76 | if ($object[$key] === $value) { 77 | continue; 78 | } 79 | 80 | $matches = false; 81 | } 82 | 83 | if ($matches) { 84 | return $object; 85 | } 86 | } 87 | 88 | return null; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataRepository/BasicObjectDataRepositoryTest.php: -------------------------------------------------------------------------------- 1 | objectDataRepository->getClassName()); 23 | } 24 | 25 | public function testFind(): void 26 | { 27 | $class = $this->createMock(ClassMetadata::class); 28 | 29 | $class->expects(self::once()) 30 | ->method('getIdentifier') 31 | ->will(self::returnValue(['_id' => 1])); 32 | 33 | $this->objectManager->expects(self::once()) 34 | ->method('getClassMetadata') 35 | ->with('TestClassName') 36 | ->will(self::returnValue($class)); 37 | 38 | self::assertEquals(['username' => 'jwage'], $this->objectDataRepository->find(1)); 39 | } 40 | 41 | protected function setUp(): void 42 | { 43 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 44 | 45 | $this->objectDataRepository = new TestBasicObjectDataRepository( 46 | $this->objectManager, 47 | 'TestClassName', 48 | ); 49 | } 50 | } 51 | 52 | class TestBasicObjectDataRepository extends BasicObjectDataRepository 53 | { 54 | /** @return string[][] */ 55 | public function findAll(): array 56 | { 57 | return [['username' => 'jwage']]; 58 | } 59 | 60 | /** 61 | * @param mixed[] $criteria 62 | * @param mixed[]|null $orderBy 63 | * 64 | * @return string[][] 65 | */ 66 | public function findBy( 67 | array $criteria, 68 | array|null $orderBy = null, 69 | int|null $limit = null, 70 | int|null $offset = null, 71 | ): array { 72 | return [['username' => 'jwage']]; 73 | } 74 | 75 | /** 76 | * @param mixed[] $criteria 77 | * 78 | * @return string[] 79 | */ 80 | public function findOneBy(array $criteria): array 81 | { 82 | return ['username' => 'jwage']; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/ArrayObjectPersister.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class ArrayObjectPersister extends BasicObjectPersister 19 | { 20 | /** 21 | * @param ArrayCollection $objects 22 | * @param class-string $className 23 | */ 24 | public function __construct( 25 | ObjectManagerInterface $objectManager, 26 | protected ArrayCollection $objects, 27 | string $className, 28 | ) { 29 | parent::__construct($objectManager, $className); 30 | } 31 | 32 | /** @return mixed[] */ 33 | public function persistObject(object $object): array 34 | { 35 | $data = $this->preparePersistChangeSet($object); 36 | 37 | $class = $this->getClassMetadata(); 38 | 39 | if (! isset($data[$class->getIdentifier()[0]])) { 40 | $data[$class->getIdentifier()[0]] = $this->generateNextId($class); 41 | } 42 | 43 | $this->objects[$data[$class->getIdentifier()[0]]] = $data; 44 | 45 | return $data; 46 | } 47 | 48 | /** @return mixed[] */ 49 | public function updateObject(object $object, ChangeSet $changeSet): array 50 | { 51 | $changeSet = $this->prepareUpdateChangeSet($object, $changeSet); 52 | 53 | $class = $this->getClassMetadata(); 54 | $identifier = $this->getObjectIdentifier($object); 55 | 56 | $objectData = $this->objects[$identifier[$class->getIdentifier()[0]]]; 57 | 58 | foreach ($changeSet as $key => $value) { 59 | $objectData[$key] = $value; 60 | } 61 | 62 | $this->objects[$objectData[$class->getIdentifier()[0]]] = $objectData; 63 | 64 | return $objectData; 65 | } 66 | 67 | public function removeObject(object $object): void 68 | { 69 | $class = $this->getClassMetadata(); 70 | $identifier = $this->getObjectIdentifier($object); 71 | 72 | unset($this->objects[$identifier[$class->getIdentifier()[0]]]); 73 | } 74 | 75 | /** @phpstan-param ClassMetadataInterface $class */ 76 | private function generateNextId(ClassMetadataInterface $class): int 77 | { 78 | $ids = []; 79 | foreach ($this->objects as $objectData) { 80 | $ids[] = $objectData[$class->getIdentifier()[0]]; 81 | } 82 | 83 | return $ids !== [] ? (int) (max($ids) + 1) : 1; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/DataSource/Sorter.php: -------------------------------------------------------------------------------- 1 | $order) { 35 | $this->fields[] = $field; 36 | $this->orders[] = $this->getOrder($order); 37 | } 38 | } 39 | 40 | /** 41 | * @param mixed[] $a 42 | * @param mixed[] $b 43 | */ 44 | public function __invoke(array $a, array $b): int 45 | { 46 | $returnVal = 0; 47 | $comparisonField = $this->fields[$this->level]; 48 | $order = $this->orders[$this->level]; 49 | $aComparisonField = $this->getComparisonField($a, $comparisonField); 50 | $bComparisonField = $this->getComparisonField($b, $comparisonField); 51 | 52 | $comparisonResult = $aComparisonField <=> $bComparisonField; 53 | 54 | if ($comparisonResult !== 0) { 55 | $returnVal = $comparisonResult; 56 | } else { 57 | if ($this->level < count($this->fields) - 1) { 58 | $this->level++; 59 | 60 | return $this->__invoke($a, $b); 61 | } 62 | } 63 | 64 | $returnVal *= $order; 65 | 66 | $this->level = 0; 67 | 68 | return $returnVal; 69 | } 70 | 71 | private function getOrder(string $order): int 72 | { 73 | $lowercaseOrder = strtolower($order); 74 | 75 | if ($lowercaseOrder === self::ORDER_ASC) { 76 | return 1; 77 | } 78 | 79 | if ($lowercaseOrder === self::ORDER_DESC) { 80 | return -1; 81 | } 82 | 83 | throw new InvalidArgumentException(sprintf( 84 | '$order value of %s is not accepted. Only a value of asc or desc is allowed.', 85 | $order, 86 | )); 87 | } 88 | 89 | /** @param mixed[] $item */ 90 | private function getComparisonField(array $item, string $field): mixed 91 | { 92 | if (! isset($item[$field])) { 93 | throw new InvalidArgumentException(sprintf('Unable to find comparison field %s', $field)); 94 | } 95 | 96 | $value = $item[$field]; 97 | 98 | return is_string($value) ? strtolower($value) : $value; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Persister/BasicObjectPersister.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | abstract class BasicObjectPersister extends ObjectPersister 19 | { 20 | /** @var ClassMetadataInterface */ 21 | protected ClassMetadataInterface|null $class = null; 22 | 23 | /** @phpstan-param class-string $className */ 24 | public function __construct(protected ObjectManagerInterface $objectManager, protected string $className) 25 | { 26 | } 27 | 28 | public function getClassName(): string 29 | { 30 | return $this->className; 31 | } 32 | 33 | /** @phpstan-return ClassMetadataInterface */ 34 | public function getClassMetadata(): ClassMetadataInterface 35 | { 36 | if ($this->class === null) { 37 | $this->class = $this->objectManager->getClassMetadata($this->className); 38 | } 39 | 40 | return $this->class; 41 | } 42 | 43 | /** 44 | * Prepares an object changeset for persistence. 45 | * 46 | * @return mixed[] 47 | */ 48 | public function preparePersistChangeSet(object $object): array 49 | { 50 | if (! $object instanceof PersistableInterface) { 51 | throw new InvalidArgumentException( 52 | sprintf('%s must implement PersistableInterface.', $object::class), 53 | ); 54 | } 55 | 56 | return $object->preparePersistChangeSet(); 57 | } 58 | 59 | /** 60 | * Prepares an object changeset for update. 61 | * 62 | * @return mixed[] 63 | */ 64 | public function prepareUpdateChangeSet(object $object, ChangeSet $changeSet): array 65 | { 66 | if (! $object instanceof PersistableInterface) { 67 | throw new InvalidArgumentException(sprintf('%s must implement PersistableInterface.', $object::class)); 68 | } 69 | 70 | return $object->prepareUpdateChangeSet($changeSet); 71 | } 72 | 73 | /** 74 | * Assign identifier to object. 75 | * 76 | * @param mixed[] $identifier 77 | */ 78 | public function assignIdentifier(object $object, array $identifier): void 79 | { 80 | if (! $object instanceof IdentifiableInterface) { 81 | throw new InvalidArgumentException(sprintf('%s must implement IdentifiableInterface.', $object::class)); 82 | } 83 | 84 | $object->assignIdentifier($identifier); 85 | } 86 | 87 | /** @return mixed[] $identifier */ 88 | protected function getObjectIdentifier(object $object): array 89 | { 90 | return $this->objectManager 91 | ->getRepository($object::class) 92 | ->getObjectIdentifier($object); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectIdentityMap.php: -------------------------------------------------------------------------------- 1 | getObjectIdentifier($object); 29 | 30 | $serialized = serialize($objectIdentifier); 31 | 32 | return isset($this->identityMap[$className][$serialized]); 33 | } 34 | 35 | /** 36 | * @param mixed[] $data 37 | * @psalm-param class-string $className 38 | */ 39 | public function tryGetById(string $className, array $data): object|null 40 | { 41 | $serialized = serialize($this->extractIdentifierFromData($className, $data)); 42 | 43 | if (isset($this->identityMap[$className][$serialized])) { 44 | return $this->identityMap[$className][$serialized]; 45 | } 46 | 47 | return null; 48 | } 49 | 50 | /** @param mixed[] $data */ 51 | public function addToIdentityMap(object $object, array $data): void 52 | { 53 | $className = $object::class; 54 | 55 | if (! isset($this->identityMap[$className])) { 56 | $this->identityMap[$object::class] = []; 57 | } 58 | 59 | $serialized = serialize($this->getObjectIdentifier($object)); 60 | 61 | $this->identityMap[$object::class][$serialized] = $object; 62 | } 63 | 64 | public function clear(string|null $objectName = null): void 65 | { 66 | if ($objectName !== null) { 67 | unset($this->identityMap[$objectName]); 68 | } else { 69 | $this->identityMap = []; 70 | } 71 | } 72 | 73 | public function detach(object $object): void 74 | { 75 | $objectIdentifier = $this->getObjectIdentifier($object); 76 | 77 | $serialized = serialize($objectIdentifier); 78 | unset($this->identityMap[$object::class][$serialized]); 79 | } 80 | 81 | public function count(): int 82 | { 83 | return count($this->identityMap); 84 | } 85 | 86 | /** @return mixed[] $identifier */ 87 | private function getObjectIdentifier(object $object): array 88 | { 89 | return $this->objectRepositoryFactory 90 | ->getRepository($object::class) 91 | ->getObjectIdentifier($object); 92 | } 93 | 94 | /** 95 | * @param mixed[] $data 96 | * @psalm-param class-string $className 97 | * 98 | * @return mixed[] 99 | */ 100 | private function extractIdentifierFromData(string $className, array $data): array 101 | { 102 | return $this->objectRepositoryFactory 103 | ->getRepository($className) 104 | ->getObjectIdentifierFromData($data); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/Group.php: -------------------------------------------------------------------------------- 1 | id = (int) $identifier['_id']; 32 | } 33 | 34 | public static function loadMetadata(ClassMetadataInterface $metadata): void 35 | { 36 | $metadata->setIdentifier(['_id']); 37 | $metadata->setIdentifierFieldNames(['id']); 38 | $metadata->mapField([ 39 | 'name' => '_id', 40 | 'fieldName' => 'id', 41 | ]); 42 | $metadata->mapField(['fieldName' => 'name']); 43 | } 44 | 45 | public function getId(): int 46 | { 47 | return $this->id; 48 | } 49 | 50 | public function setId(int $id): void 51 | { 52 | if ($this->id === $id) { 53 | return; 54 | } 55 | 56 | $this->onPropertyChanged('id', $this->id, $id); 57 | $this->id = $id; 58 | } 59 | 60 | public function getName(): string|null 61 | { 62 | return $this->name; 63 | } 64 | 65 | public function setName(string $name): void 66 | { 67 | if ($this->name === $name) { 68 | return; 69 | } 70 | 71 | $this->onPropertyChanged('name', $this->name, $name); 72 | $this->name = $name; 73 | } 74 | 75 | /** 76 | * @see HydratableInterface 77 | * 78 | * @param mixed[] $data 79 | */ 80 | public function hydrate(array $data, ObjectManagerInterface $objectManager): void 81 | { 82 | if (isset($data['_id'])) { 83 | $this->id = $data['_id']; 84 | } 85 | 86 | if (! isset($data['name'])) { 87 | return; 88 | } 89 | 90 | $this->name = $data['name']; 91 | } 92 | 93 | /** 94 | * @see PersistableInterface 95 | * 96 | * @return mixed[] 97 | */ 98 | public function preparePersistChangeSet(): array 99 | { 100 | $changeSet = [ 101 | 'name' => $this->name, 102 | ]; 103 | 104 | if ($this->id !== null) { 105 | $changeSet['_id'] = $this->id; 106 | } 107 | 108 | return $changeSet; 109 | } 110 | 111 | /** 112 | * @see PersistableInterface 113 | * 114 | * @return mixed[] 115 | */ 116 | public function prepareUpdateChangeSet(ChangeSet $changeSet): array 117 | { 118 | $changeSet = array_map(static function (Change $change) { 119 | return $change->getNewValue(); 120 | }, $changeSet->getChanges()); 121 | 122 | $changeSet['_id'] = $this->id; 123 | 124 | return $changeSet; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/Address.php: -------------------------------------------------------------------------------- 1 | address1; 29 | } 30 | 31 | /** 32 | * Sets the value of address1. 33 | * 34 | * @param string $address1 the address1 35 | */ 36 | public function setAddress1(string $address1): void 37 | { 38 | if ($this->address1 === $address1) { 39 | return; 40 | } 41 | 42 | $this->profile->addressChanged('address1', $this->address1, $address1); 43 | $this->address1 = $address1; 44 | } 45 | 46 | /** 47 | * Gets the value of address2. 48 | */ 49 | public function getAddress2(): string|null 50 | { 51 | return $this->address2; 52 | } 53 | 54 | /** 55 | * Sets the value of address2. 56 | * 57 | * @param string $address2 the address2 58 | */ 59 | public function setAddress2(string $address2): void 60 | { 61 | if ($this->address2 === $address2) { 62 | return; 63 | } 64 | 65 | $this->profile->addressChanged('address2', $this->address2, $address2); 66 | $this->address2 = $address2; 67 | } 68 | 69 | /** 70 | * Gets the value of city. 71 | */ 72 | public function getCity(): string 73 | { 74 | return $this->city; 75 | } 76 | 77 | /** 78 | * Sets the value of city. 79 | * 80 | * @param string $city the city 81 | */ 82 | public function setCity(string $city): void 83 | { 84 | if ($this->city === $city) { 85 | return; 86 | } 87 | 88 | $this->profile->addressChanged('city', $this->city, $city); 89 | $this->city = $city; 90 | } 91 | 92 | /** 93 | * Gets the value of state. 94 | */ 95 | public function getState(): string 96 | { 97 | return $this->state; 98 | } 99 | 100 | /** 101 | * Sets the value of state. 102 | * 103 | * @param string $state the state 104 | */ 105 | public function setState(string $state): void 106 | { 107 | if ($this->state === $state) { 108 | return; 109 | } 110 | 111 | $this->profile->addressChanged('state', $this->state, $state); 112 | $this->state = $state; 113 | } 114 | 115 | /** 116 | * Gets the value of zip. 117 | */ 118 | public function getZip(): string 119 | { 120 | return $this->zip; 121 | } 122 | 123 | /** 124 | * Sets the value of zip. 125 | * 126 | * @param string $zip the zip 127 | */ 128 | public function setZip(string $zip): void 129 | { 130 | if ($this->zip === $zip) { 131 | return; 132 | } 133 | 134 | $this->profile->addressChanged('zip', $this->zip, $zip); 135 | $this->zip = $zip; 136 | } 137 | 138 | public function setProfile(Profile $profile): void 139 | { 140 | $this->profile = $profile; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/DataSource/DataSourceObjectDataRepository.php: -------------------------------------------------------------------------------- 1 | getSourceRows(); 31 | } 32 | 33 | /** 34 | * @param mixed[] $criteria 35 | * @param mixed[] $orderBy 36 | * 37 | * @return mixed[][] 38 | */ 39 | public function findBy( 40 | array $criteria, 41 | array|null $orderBy = null, 42 | int|null $limit = null, 43 | int|null $offset = null, 44 | ): array { 45 | $rows = []; 46 | 47 | foreach ($this->getSourceRows() as $row) { 48 | if (! $this->matches($criteria, $row)) { 49 | continue; 50 | } 51 | 52 | $rows[] = $row; 53 | } 54 | 55 | if ($orderBy !== null && $orderBy !== []) { 56 | $rows = $this->sort($rows, $orderBy); 57 | } 58 | 59 | if ($limit !== null || $offset !== null) { 60 | return $this->slice($rows, $limit, $offset); 61 | } 62 | 63 | return $rows; 64 | } 65 | 66 | /** 67 | * @param mixed[] $criteria 68 | * 69 | * @return mixed[]|null 70 | */ 71 | public function findOneBy(array $criteria): array|null 72 | { 73 | foreach ($this->getSourceRows() as $row) { 74 | if ($this->matches($criteria, $row)) { 75 | return $row; 76 | } 77 | } 78 | 79 | return null; 80 | } 81 | 82 | /** 83 | * @param mixed[] $criteria 84 | * @param mixed[] $row 85 | */ 86 | private function matches(array $criteria, array $row): bool 87 | { 88 | return (new CriteriaMatcher($criteria, $row))->matches(); 89 | } 90 | 91 | /** 92 | * @param mixed[][] $rows 93 | * @param string[] $orderBy 94 | * 95 | * @return mixed[][] $rows 96 | */ 97 | private function sort(array $rows, array $orderBy): array 98 | { 99 | usort($rows, new Sorter($orderBy)); 100 | 101 | return $rows; 102 | } 103 | 104 | /** 105 | * @param mixed[][] $rows 106 | * 107 | * @return mixed[][] $rows 108 | */ 109 | private function slice(array $rows, int|null $limit, int|null $offset): array 110 | { 111 | if ($limit === null) { 112 | $limit = count($rows); 113 | } 114 | 115 | if ($offset === null) { 116 | $offset = 0; 117 | } 118 | 119 | return array_slice($rows, $offset, $limit); 120 | } 121 | 122 | /** @return mixed[][] */ 123 | private function getSourceRows(): array 124 | { 125 | if ($this->sourceRows === null) { 126 | $this->sourceRows = $this->dataSource->getSourceRows(); 127 | } 128 | 129 | return $this->sourceRows; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/ObjectRepository/BasicObjectRepositoryTest.php: -------------------------------------------------------------------------------- 1 | */ 33 | private ClassMetadata $classMetadata; 34 | 35 | private BasicObjectRepository $repository; 36 | 37 | /** @phpstan-var class-string */ 38 | private $testClassName = BasicObjectRepositoryTestModel::class; 39 | 40 | public function testGetObjectIdentifier(): void 41 | { 42 | $object = new BasicObjectRepositoryTestModel(); 43 | $object->id = 1; 44 | 45 | $data = ['id' => 1]; 46 | self::assertEquals($data, $this->repository->getObjectIdentifier($object)); 47 | } 48 | 49 | public function testGetObjectIdentifierFromData(): void 50 | { 51 | $data = ['id' => 1]; 52 | self::assertEquals($data, $this->repository->getObjectIdentifierFromData($data)); 53 | } 54 | 55 | public function testMerge(): void 56 | { 57 | $this->expectException(BadMethodCallException::class); 58 | $this->expectExceptionMessage('Not implemented.'); 59 | 60 | $this->repository->merge(new stdClass()); 61 | } 62 | 63 | protected function setUp(): void 64 | { 65 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 66 | 67 | $this->objectDataRepository = $this->createMock(ObjectDataRepositoryInterface::class); 68 | 69 | $this->objectFactory = $this->createMock(ObjectFactory::class); 70 | 71 | $this->hydrator = $this->createMock(ObjectHydratorInterface::class); 72 | 73 | $this->eventManager = $this->createMock(EventManager::class); 74 | 75 | $this->classMetadata = new ClassMetadata($this->testClassName); 76 | $this->classMetadata->identifier = ['id']; 77 | $this->classMetadata->identifierFieldNames = ['id']; 78 | $this->classMetadata->mapField(['fieldName' => 'id']); 79 | 80 | $this->objectManager->expects(self::any()) 81 | ->method('getClassMetadata') 82 | ->with($this->testClassName) 83 | ->will(self::returnValue($this->classMetadata)); 84 | 85 | $this->repository = new BasicObjectRepository( 86 | $this->objectManager, 87 | $this->objectDataRepository, 88 | $this->objectFactory, 89 | $this->hydrator, 90 | $this->eventManager, 91 | $this->testClassName, 92 | ); 93 | } 94 | } 95 | 96 | class BasicObjectRepositoryTestModel 97 | { 98 | public int $id; 99 | } 100 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Events.php: -------------------------------------------------------------------------------- 1 | users = new ArrayCollection([ 22 | 1 => [ 23 | '_id' => 1, 24 | 'username' => 'jwage', 25 | 'password' => 'password', 26 | ], 27 | 2 => [ 28 | '_id' => 2, 29 | 'username' => 'romanb', 30 | 'password' => 'password', 31 | ], 32 | ]); 33 | $this->profiles = new ArrayCollection(); 34 | $this->groups = new ArrayCollection(); 35 | 36 | $this->usersTester = new ArrayTester($this->users); 37 | $this->profilesTester = new ArrayTester($this->profiles); 38 | $this->groupsTester = new ArrayTester($this->groups); 39 | 40 | $this->setUpCommon(); 41 | } 42 | 43 | protected function createUserDataRepository(): ObjectDataRepository 44 | { 45 | return new ArrayObjectDataRepository( 46 | $this->objectManager, 47 | $this->users, 48 | User::class, 49 | ); 50 | } 51 | 52 | protected function createUserPersister(): ObjectPersister 53 | { 54 | return new ArrayObjectPersister( 55 | $this->objectManager, 56 | $this->users, 57 | User::class, 58 | ); 59 | } 60 | 61 | protected function createProfileDataRepository(): ObjectDataRepository 62 | { 63 | return new ArrayObjectDataRepository( 64 | $this->objectManager, 65 | $this->profiles, 66 | Profile::class, 67 | ); 68 | } 69 | 70 | protected function createProfilePersister(): ObjectPersister 71 | { 72 | return new ArrayObjectPersister( 73 | $this->objectManager, 74 | $this->profiles, 75 | Profile::class, 76 | ); 77 | } 78 | 79 | protected function createGroupDataRepository(): ObjectDataRepository 80 | { 81 | return new ArrayObjectDataRepository( 82 | $this->objectManager, 83 | $this->groups, 84 | Group::class, 85 | ); 86 | } 87 | 88 | protected function createGroupPersister(): ObjectPersister 89 | { 90 | return new ArrayObjectPersister( 91 | $this->objectManager, 92 | $this->groups, 93 | Group::class, 94 | ); 95 | } 96 | } 97 | 98 | class ArrayTester implements DataTesterInterface 99 | { 100 | /** @param ArrayCollection $objects */ 101 | public function __construct(private ArrayCollection $objects) 102 | { 103 | } 104 | 105 | /** @return mixed[] */ 106 | public function find(int $id): array|null 107 | { 108 | foreach ($this->objects as $object) { 109 | if ($object['_id'] === $id) { 110 | return $object; 111 | } 112 | } 113 | 114 | return null; 115 | } 116 | 117 | public function set(int $id, string $key, mixed $value): void 118 | { 119 | $object = $this->objects[$id]; 120 | $object[$key] = $value; 121 | $this->objects[$id] = $object; 122 | } 123 | 124 | public function count(): int 125 | { 126 | return $this->objects->count(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Event/PreUpdateEventArgsTest.php: -------------------------------------------------------------------------------- 1 | changeSet, $this->event->getObjectChangeSet()); 28 | } 29 | 30 | public function testHasChangedField(): void 31 | { 32 | $this->changeSet->expects(self::once()) 33 | ->method('hasChangedField') 34 | ->with('username') 35 | ->will(self::returnValue(true)); 36 | 37 | self::assertTrue($this->event->hasChangedField('username')); 38 | } 39 | 40 | public function testGetOldValue(): void 41 | { 42 | $change = new Change('username', 'jwage', 'jonwage'); 43 | 44 | $this->changeSet->expects(self::once()) 45 | ->method('getFieldChange') 46 | ->with('username') 47 | ->will(self::returnValue($change)); 48 | 49 | self::assertEquals('jwage', $this->event->getOldValue('username')); 50 | } 51 | 52 | public function testGetOldValueReturnsNull(): void 53 | { 54 | $this->changeSet->expects(self::once()) 55 | ->method('getFieldChange') 56 | ->with('username') 57 | ->will(self::returnValue(null)); 58 | 59 | self::assertNull($this->event->getOldValue('username')); 60 | } 61 | 62 | public function testGetNewValue(): void 63 | { 64 | $change = new Change('username', 'jwage', 'jonwage'); 65 | 66 | $this->changeSet->expects(self::once()) 67 | ->method('getFieldChange') 68 | ->with('username') 69 | ->will(self::returnValue($change)); 70 | 71 | self::assertEquals('jonwage', $this->event->getNewValue('username')); 72 | } 73 | 74 | public function testGetNewValueReturnsNull(): void 75 | { 76 | $this->changeSet->expects(self::once()) 77 | ->method('getFieldChange') 78 | ->with('username') 79 | ->will(self::returnValue(null)); 80 | 81 | self::assertNull($this->event->getNewValue('username')); 82 | } 83 | 84 | public function testSetNewValue(): void 85 | { 86 | $change = new Change('username', 'jwage', 'jonwage'); 87 | 88 | $this->changeSet->expects(self::any()) 89 | ->method('getFieldChange') 90 | ->with('username') 91 | ->will(self::returnValue($change)); 92 | 93 | $this->event->setNewValue('username', 'jonathan'); 94 | 95 | self::assertEquals('jonathan', $this->event->getNewValue('username')); 96 | } 97 | 98 | public function testSetNewValueForUnchangedField(): void 99 | { 100 | $change = new Change('username', null, 'jonwage'); 101 | 102 | $this->changeSet->expects(self::once()) 103 | ->method('getFieldChange') 104 | ->with('username') 105 | ->will(self::returnValue(null)); 106 | 107 | $this->changeSet->expects(self::once()) 108 | ->method('addChange') 109 | ->with($change); 110 | 111 | $this->event->setNewValue('username', 'jonwage'); 112 | } 113 | 114 | protected function setUp(): void 115 | { 116 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 117 | 118 | $this->object = new stdClass(); 119 | 120 | $this->changeSet = $this->createMock(ChangeSet::class); 121 | 122 | $this->event = new PreUpdateEventArgs($this->object, $this->objectManager, $this->changeSet); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataSource/DataSourceObjectDataRepositoryTest.php: -------------------------------------------------------------------------------- 1 | objectManager = $this->createMock(ObjectManagerInterface::class); 25 | $this->dataSource = $this->createMock(DataSource::class); 26 | 27 | $this->dataSourceObjectDataRepository = new DataSourceObjectDataRepository( 28 | $this->objectManager, 29 | $this->dataSource, 30 | stdClass::class, 31 | ); 32 | } 33 | 34 | public function testFindAll(): void 35 | { 36 | $rows = [ 37 | ['row' => 1], 38 | ['row' => 2], 39 | ]; 40 | 41 | $this->dataSource->expects(self::once()) 42 | ->method('getSourceRows') 43 | ->willReturn($rows); 44 | 45 | self::assertEquals($rows, $this->dataSourceObjectDataRepository->findAll()); 46 | } 47 | 48 | public function testFindByCriteria(): void 49 | { 50 | $rows = [ 51 | ['username' => 'jwage'], 52 | ['username' => 'ocramius'], 53 | ]; 54 | 55 | $this->dataSource->expects(self::once()) 56 | ->method('getSourceRows') 57 | ->willReturn($rows); 58 | 59 | self::assertEquals([ 60 | ['username' => 'ocramius'], 61 | ], $this->dataSourceObjectDataRepository->findBy(['username' => 'ocramius'])); 62 | } 63 | 64 | public function testFindByOrderBy(): void 65 | { 66 | $rows = [ 67 | ['username' => 'jwage'], 68 | ['username' => 'ocramius'], 69 | ]; 70 | 71 | $this->dataSource->expects(self::once()) 72 | ->method('getSourceRows') 73 | ->willReturn($rows); 74 | 75 | self::assertEquals([ 76 | ['username' => 'ocramius'], 77 | ['username' => 'jwage'], 78 | ], $this->dataSourceObjectDataRepository->findBy([], ['username' => 'desc'])); 79 | } 80 | 81 | public function testFindByLimitAndOffset(): void 82 | { 83 | $rows = [ 84 | ['username' => 'jwage'], 85 | ['username' => 'ocramius'], 86 | ['username' => 'andreas'], 87 | ]; 88 | 89 | $this->dataSource->expects(self::once()) 90 | ->method('getSourceRows') 91 | ->willReturn($rows); 92 | 93 | self::assertEquals([ 94 | ['username' => 'ocramius'], 95 | ['username' => 'andreas'], 96 | ], $this->dataSourceObjectDataRepository->findBy([], [], 2, 1)); 97 | } 98 | 99 | public function testFindByLimit(): void 100 | { 101 | $rows = [ 102 | ['username' => 'jwage'], 103 | ['username' => 'ocramius'], 104 | ['username' => 'andreas'], 105 | ]; 106 | 107 | $this->dataSource->expects(self::once()) 108 | ->method('getSourceRows') 109 | ->willReturn($rows); 110 | 111 | self::assertEquals([ 112 | ['username' => 'jwage'], 113 | ], $this->dataSourceObjectDataRepository->findBy([], [], 1)); 114 | } 115 | 116 | public function testFindByOffset(): void 117 | { 118 | $rows = [ 119 | ['username' => 'jwage'], 120 | ['username' => 'ocramius'], 121 | ['username' => 'andreas'], 122 | ]; 123 | 124 | $this->dataSource->expects(self::once()) 125 | ->method('getSourceRows') 126 | ->willReturn($rows); 127 | 128 | self::assertEquals([ 129 | ['username' => 'ocramius'], 130 | ['username' => 'andreas'], 131 | ], $this->dataSourceObjectDataRepository->findBy([], [], null, 1)); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Persister/ArrayObjectPersisterTest.php: -------------------------------------------------------------------------------- 1 | > */ 23 | private ArrayCollection $objects; 24 | 25 | private ArrayObjectPersister $persister; 26 | 27 | /** @phpstan-var class-string */ 28 | private $testClassName = ArrayObjectPersisterTestModel::class; 29 | 30 | public function testPersistObject(): void 31 | { 32 | $object = new ArrayObjectPersisterTestModel(); 33 | 34 | self::assertEquals(['username' => 'jwage', 'id' => 1], $this->persister->persistObject($object)); 35 | self::assertEquals([1 => ['username' => 'jwage', 'id' => 1]], $this->objects->toArray()); 36 | } 37 | 38 | public function testUpdateObject(): void 39 | { 40 | $this->objects[1] = [ 41 | 'id' => 1, 42 | 'username' => 'jwage', 43 | ]; 44 | 45 | $object = new ArrayObjectPersisterTestModel(); 46 | 47 | $changeSet = new ChangeSet($object, [new Change('username', 'jwage', 'jonwage')]); 48 | 49 | $repository = $this->getMockBuilder('Doctrine\SkeletonMapper\ObjectRepository\ObjectRepositoryInterface') 50 | ->disableOriginalConstructor() 51 | ->getMock(); 52 | 53 | $repository->expects(self::once()) 54 | ->method('getObjectIdentifier') 55 | ->with($object) 56 | ->will(self::returnValue(['id' => 1])); 57 | 58 | $this->objectManager->expects(self::once()) 59 | ->method('getRepository') 60 | ->with($this->testClassName) 61 | ->will(self::returnValue($repository)); 62 | 63 | self::assertEquals(['username' => 'jonwage', 'id' => 1], $this->persister->updateObject($object, $changeSet)); 64 | self::assertEquals([1 => ['username' => 'jonwage', 'id' => 1]], $this->objects->toArray()); 65 | } 66 | 67 | public function testRemoveObject(): void 68 | { 69 | $this->objects[1] = [ 70 | 'id' => 1, 71 | 'username' => 'jwage', 72 | ]; 73 | 74 | $object = new ArrayObjectPersisterTestModel(); 75 | 76 | $repository = $this->getMockBuilder('Doctrine\SkeletonMapper\ObjectRepository\ObjectRepositoryInterface') 77 | ->disableOriginalConstructor() 78 | ->getMock(); 79 | 80 | $repository->expects(self::once()) 81 | ->method('getObjectIdentifier') 82 | ->with($object) 83 | ->will(self::returnValue(['id' => 1])); 84 | 85 | $this->objectManager->expects(self::once()) 86 | ->method('getRepository') 87 | ->with($this->testClassName) 88 | ->will(self::returnValue($repository)); 89 | 90 | $this->persister->removeObject($object); 91 | 92 | self::assertCount(0, $this->objects); 93 | } 94 | 95 | protected function setUp(): void 96 | { 97 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 98 | 99 | $classMetadata = new ClassMetadata($this->testClassName); 100 | $classMetadata->identifier = ['id']; 101 | 102 | $this->objectManager->expects(self::any()) 103 | ->method('getClassMetadata') 104 | ->with($this->testClassName) 105 | ->will(self::returnValue($classMetadata)); 106 | 107 | $this->objects = new ArrayCollection(); 108 | $this->persister = new ArrayObjectPersister( 109 | $this->objectManager, 110 | $this->objects, 111 | $this->testClassName, 112 | ); 113 | } 114 | } 115 | 116 | class ArrayObjectPersisterTestModel implements PersistableInterface 117 | { 118 | /** @return string[] */ 119 | public function preparePersistChangeSet(): array 120 | { 121 | return ['username' => 'jwage']; 122 | } 123 | 124 | /** @return string[] */ 125 | public function prepareUpdateChangeSet(ChangeSet $changeSet): array 126 | { 127 | $changes = []; 128 | 129 | foreach ($changeSet->getChanges() as $change) { 130 | $changes[$change->getPropertyName()] = $change->getNewValue(); 131 | } 132 | 133 | return $changes; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectManager.php: -------------------------------------------------------------------------------- 1 | > $metadataFactory 27 | * @param ObjectPersisterFactoryInterface $objectPersisterFactory 28 | */ 29 | public function __construct( 30 | private ObjectRepositoryFactoryInterface $objectRepositoryFactory, 31 | private ObjectPersisterFactoryInterface $objectPersisterFactory, 32 | private ObjectIdentityMap $objectIdentityMap, 33 | private ClassMetadataFactory $metadataFactory, 34 | EventManager|null $eventManager = null, 35 | ) { 36 | $this->eventManager = $eventManager ?? new EventManager(); 37 | 38 | $this->unitOfWork = new UnitOfWork( 39 | $this, 40 | $this->objectPersisterFactory, 41 | $this->objectIdentityMap, 42 | $this->eventManager, 43 | ); 44 | } 45 | 46 | public function getUnitOfWork(): UnitOfWork 47 | { 48 | return $this->unitOfWork; 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | * 54 | * @psalm-param class-string $className 55 | * 56 | * @psalm-return object|null 57 | */ 58 | public function find(string $className, $id) 59 | { 60 | return $this->getRepository($className)->find($id); 61 | } 62 | 63 | public function persist(object $object): void 64 | { 65 | $this->unitOfWork->persist($object); 66 | } 67 | 68 | /** 69 | * Tells the ObjectManager to update the object on flush. 70 | * 71 | * The object will be updated in the database as a result of the flush operation. 72 | * 73 | * {@inheritDoc} 74 | */ 75 | public function update(object $object): void 76 | { 77 | $this->unitOfWork->update($object); 78 | } 79 | 80 | public function remove(object $object): void 81 | { 82 | $this->unitOfWork->remove($object); 83 | } 84 | 85 | public function merge(object $object): void 86 | { 87 | $this->unitOfWork->merge($object); 88 | } 89 | 90 | public function clear(): void 91 | { 92 | $this->unitOfWork->clear(); 93 | } 94 | 95 | public function detach(object $object): void 96 | { 97 | $this->unitOfWork->detach($object); 98 | } 99 | 100 | public function refresh(object $object): void 101 | { 102 | $this->unitOfWork->refresh($object); 103 | } 104 | 105 | public function flush(): void 106 | { 107 | $this->unitOfWork->commit(); 108 | } 109 | 110 | public function getRepository(string $className): ObjectRepositoryInterface 111 | { 112 | return $this->objectRepositoryFactory->getRepository($className); 113 | } 114 | 115 | public function getClassMetadata(string $className): ClassMetadataInterface 116 | { 117 | return $this->metadataFactory->getMetadataFor($className); 118 | } 119 | 120 | /** 121 | * Gets the metadata factory used to gather the metadata of classes. 122 | * 123 | * @psalm-return ClassMetadataFactory> 124 | */ 125 | public function getMetadataFactory() 126 | { 127 | return $this->metadataFactory; 128 | } 129 | 130 | /** 131 | * Helper method to initialize a lazy loading proxy or persistent collection. 132 | * 133 | * This method is a no-op for other objects. 134 | * 135 | * {@inheritDoc} 136 | */ 137 | public function initializeObject(object $obj): void 138 | { 139 | throw new BadMethodCallException('Not supported.'); 140 | } 141 | 142 | /** 143 | * Checks if the object is part of the current UnitOfWork and therefore managed. 144 | * 145 | * {@inheritDoc} 146 | */ 147 | public function contains(object $object): bool 148 | { 149 | return $this->unitOfWork->contains($object); 150 | } 151 | 152 | /** 153 | * @param mixed[] $data 154 | * @phpstan-param class-string $className 155 | */ 156 | public function getOrCreateObject(string $className, array $data): object 157 | { 158 | return $this->unitOfWork->getOrCreateObject($className, $data); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork/EventDispatcher.php: -------------------------------------------------------------------------------- 1 | eventManager->hasListeners($eventName)) { 27 | return; 28 | } 29 | 30 | $this->eventManager->dispatchEvent($eventName, $event); 31 | } 32 | 33 | /** @param mixed[] $args */ 34 | public function dispatchObjectLifecycleCallback(string $eventName, object $object, array &$args = []): void 35 | { 36 | $className = $object::class; 37 | 38 | $class = $this->objectManager->getClassMetadata($className); 39 | 40 | if (! $class->hasLifecycleCallbacks($eventName)) { 41 | return; 42 | } 43 | 44 | $class->invokeLifecycleCallbacks($eventName, $object, $args); 45 | } 46 | 47 | /** @param object[] $objects */ 48 | public function dispatchObjectsLifecycleCallbacks(string $eventName, array $objects): void 49 | { 50 | foreach ($objects as $object) { 51 | $this->dispatchObjectLifecycleCallback($eventName, $object); 52 | } 53 | } 54 | 55 | public function dispatchPreFlush(): void 56 | { 57 | $this->dispatchEvent( 58 | Events::preFlush, 59 | new Event\PreFlushEventArgs($this->objectManager), 60 | ); 61 | } 62 | 63 | /** @param object[] $objects */ 64 | public function dispatchPreFlushLifecycleCallbacks(array $objects): void 65 | { 66 | $this->dispatchObjectsLifecycleCallbacks(Events::preFlush, $objects); 67 | } 68 | 69 | public function dispatchOnFlush(): void 70 | { 71 | $this->dispatchEvent( 72 | Events::onFlush, 73 | new Event\OnFlushEventArgs($this->objectManager), 74 | ); 75 | } 76 | 77 | public function dispatchPostFlush(): void 78 | { 79 | $this->dispatchEvent( 80 | Events::postFlush, 81 | new Event\PostFlushEventArgs($this->objectManager), 82 | ); 83 | } 84 | 85 | public function dispatchOnClearEvent(string|null $className): void 86 | { 87 | $this->dispatchEvent( 88 | Events::onClear, 89 | new Event\OnClearEventArgs($this->objectManager), 90 | ); 91 | } 92 | 93 | public function dispatchPreRemove(object $object): void 94 | { 95 | $this->dispatchObjectLifecycleCallback(Events::preRemove, $object); 96 | 97 | $this->dispatchEvent( 98 | Events::preRemove, 99 | new LifecycleEventArgs($object, $this->objectManager), 100 | ); 101 | } 102 | 103 | public function dispatchPreUpdate(object $object, ChangeSet $changeSet): void 104 | { 105 | $args = [$changeSet]; 106 | $this->dispatchObjectLifecycleCallback(Events::preUpdate, $object, $args); 107 | 108 | $this->dispatchEvent( 109 | Events::preUpdate, 110 | new PreUpdateEventArgs( 111 | $object, 112 | $this->objectManager, 113 | $changeSet, 114 | ), 115 | ); 116 | } 117 | 118 | public function dispatchPrePersist(object $object): void 119 | { 120 | $this->dispatchObjectLifecycleCallback(Events::prePersist, $object); 121 | 122 | $this->dispatchEvent( 123 | Events::prePersist, 124 | new LifecycleEventArgs($object, $this->objectManager), 125 | ); 126 | } 127 | 128 | /** @param mixed[] $data */ 129 | public function dispatchPreLoad(object $object, array &$data): void 130 | { 131 | $args = [&$data]; 132 | $this->dispatchObjectLifecycleCallback(Events::preLoad, $object, $args); 133 | 134 | $this->dispatchEvent( 135 | Events::preLoad, 136 | new PreLoadEventArgs($object, $this->objectManager, $data), 137 | ); 138 | } 139 | 140 | public function dispatchPostLoad(object $object): void 141 | { 142 | $this->dispatchLifecycleEvent(Events::postLoad, $object); 143 | } 144 | 145 | public function dispatchLifecycleEvent(string $eventName, object $object): void 146 | { 147 | $this->dispatchObjectLifecycleCallback($eventName, $object); 148 | 149 | $this->dispatchEvent( 150 | $eventName, 151 | new LifecycleEventArgs($object, $this->objectManager), 152 | ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/ObjectRepository/ObjectRepository.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | abstract class ObjectRepository implements ObjectRepositoryInterface 22 | { 23 | /** @phpstan-var class-string */ 24 | protected $className; 25 | 26 | /** @var ClassMetadataInterface */ 27 | protected ClassMetadataInterface $class; 28 | 29 | /** @phpstan-param class-string $className */ 30 | public function __construct( 31 | protected ObjectManagerInterface $objectManager, 32 | protected ObjectDataRepositoryInterface $objectDataRepository, 33 | protected ObjectFactory $objectFactory, 34 | protected ObjectHydratorInterface $objectHydrator, 35 | protected EventManager $eventManager, 36 | string $className, 37 | ) { 38 | $this->setClassName($className); 39 | } 40 | 41 | /** 42 | * Returns the class name of the object managed by the repository. 43 | * 44 | * @phpstan-return class-string 45 | */ 46 | public function getClassName(): string 47 | { 48 | return $this->className; 49 | } 50 | 51 | /** @phpstan-param class-string $className */ 52 | public function setClassName(string $className): void 53 | { 54 | $this->className = $className; 55 | $this->class = $this->objectManager->getClassMetadata($this->className); 56 | } 57 | 58 | /** 59 | * Finds an object by its primary key / identifier. 60 | * 61 | * {@inheritDoc} 62 | * 63 | * @psalm-return T|null 64 | */ 65 | public function find($id) 66 | { 67 | return $this->getOrCreateObject( 68 | $this->objectDataRepository->find($id), 69 | ); 70 | } 71 | 72 | /** 73 | * Finds all objects in the repository. 74 | * 75 | * @return object[] The objects. 76 | */ 77 | public function findAll(): array 78 | { 79 | $objectsData = $this->objectDataRepository->findAll(); 80 | 81 | $objects = []; 82 | foreach ($objectsData as $objectData) { 83 | $object = $this->getOrCreateObject($objectData); 84 | 85 | if ($object === null) { 86 | throw new InvalidArgumentException('Could not create object.'); 87 | } 88 | 89 | $objects[] = $object; 90 | } 91 | 92 | return $objects; 93 | } 94 | 95 | /** 96 | * {@inheritDoc} 97 | */ 98 | public function findBy(array $criteria, array|null $orderBy = null, int|null $limit = null, int|null $offset = null): array 99 | { 100 | $objectsData = $this->objectDataRepository->findBy( 101 | $criteria, 102 | $orderBy, 103 | $limit, 104 | $offset, 105 | ); 106 | 107 | $objects = []; 108 | foreach ($objectsData as $objectData) { 109 | $object = $this->getOrCreateObject($objectData); 110 | 111 | if ($object === null) { 112 | throw new InvalidArgumentException('Could not create object.'); 113 | } 114 | 115 | $objects[] = $object; 116 | } 117 | 118 | return $objects; 119 | } 120 | 121 | /** 122 | * {@inheritDoc} 123 | */ 124 | public function findOneBy(array $criteria) 125 | { 126 | return $this->getOrCreateObject( 127 | $this->objectDataRepository->findOneBy($criteria), 128 | ); 129 | } 130 | 131 | public function refresh(object $object): void 132 | { 133 | $data = $this->objectDataRepository 134 | ->find($this->getObjectIdentifier($object)); 135 | 136 | if ($data === null) { 137 | throw new InvalidArgumentException('Could not find object to refresh.'); 138 | } 139 | 140 | $this->hydrate($object, $data); 141 | } 142 | 143 | /** @param mixed[] $data */ 144 | public function hydrate(object $object, array $data): void 145 | { 146 | $this->objectHydrator->hydrate($object, $data); 147 | } 148 | 149 | /** @phpstan-param class-string $className */ 150 | public function create(string $className): object 151 | { 152 | return $this->objectFactory->create($className); 153 | } 154 | 155 | /** 156 | * @param mixed[] $data 157 | * 158 | * @psalm-return T|null 159 | */ 160 | protected function getOrCreateObject(array|null $data = null) 161 | { 162 | if ($data === null) { 163 | return null; 164 | } 165 | 166 | return $this->objectManager->getOrCreateObject( 167 | $this->getClassName(), 168 | $data, 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/DataSource/SorterTest.php: -------------------------------------------------------------------------------- 1 | 'asc']); 18 | 19 | $rows = [ 20 | ['numComments' => 2], 21 | ['numComments' => 1], 22 | ]; 23 | 24 | usort($rows, $sorter); 25 | 26 | self::assertEquals([ 27 | ['numComments' => 1], 28 | ['numComments' => 2], 29 | ], $rows); 30 | } 31 | 32 | public function testSingleDesc(): void 33 | { 34 | $sorter = new Sorter(['numComments' => 'desc']); 35 | 36 | $rows = [ 37 | ['numComments' => 1], 38 | ['numComments' => 2], 39 | ]; 40 | 41 | usort($rows, $sorter); 42 | 43 | self::assertEquals([ 44 | ['numComments' => 2], 45 | ['numComments' => 1], 46 | ], $rows); 47 | } 48 | 49 | public function testMultipleAsc(): void 50 | { 51 | $sorter = new Sorter(['numComments' => 'asc', 'name' => 'asc']); 52 | 53 | $rows = [ 54 | [ 55 | 'name' => 'Andreas', 56 | 'numComments' => 1, 57 | ], 58 | [ 59 | 'name' => 'Marco', 60 | 'numComments' => 2, 61 | ], 62 | [ 63 | 'name' => 'Jon', 64 | 'numComments' => 2, 65 | ], 66 | ]; 67 | 68 | usort($rows, $sorter); 69 | 70 | self::assertEquals([ 71 | [ 72 | 'name' => 'Andreas', 73 | 'numComments' => 1, 74 | ], 75 | [ 76 | 'name' => 'Jon', 77 | 'numComments' => 2, 78 | ], 79 | [ 80 | 'name' => 'Marco', 81 | 'numComments' => 2, 82 | ], 83 | ], $rows); 84 | } 85 | 86 | public function testMultipleDesc(): void 87 | { 88 | $sorter = new Sorter(['numComments' => 'desc', 'name' => 'desc']); 89 | 90 | $rows = [ 91 | [ 92 | 'name' => 'Andreas', 93 | 'numComments' => 2, 94 | ], 95 | [ 96 | 'name' => 'Marco', 97 | 'numComments' => 1, 98 | ], 99 | [ 100 | 'name' => 'Jon', 101 | 'numComments' => 1, 102 | ], 103 | ]; 104 | 105 | usort($rows, $sorter); 106 | 107 | self::assertEquals([ 108 | [ 109 | 'name' => 'Andreas', 110 | 'numComments' => 2, 111 | ], 112 | [ 113 | 'name' => 'Marco', 114 | 'numComments' => 1, 115 | ], 116 | [ 117 | 'name' => 'Jon', 118 | 'numComments' => 1, 119 | ], 120 | ], $rows); 121 | } 122 | 123 | public function testMultipleMixed(): void 124 | { 125 | $sorter = new Sorter(['numComments' => 'desc', 'name' => 'asc']); 126 | 127 | $rows = [ 128 | [ 129 | 'name' => 'Andreas', 130 | 'numComments' => 2, 131 | ], 132 | [ 133 | 'name' => 'Marco', 134 | 'numComments' => 1, 135 | ], 136 | [ 137 | 'name' => 'Jon', 138 | 'numComments' => 1, 139 | ], 140 | ]; 141 | 142 | usort($rows, $sorter); 143 | 144 | self::assertEquals([ 145 | [ 146 | 'name' => 'Andreas', 147 | 'numComments' => 2, 148 | ], 149 | [ 150 | 'name' => 'Jon', 151 | 'numComments' => 1, 152 | ], 153 | [ 154 | 'name' => 'Marco', 155 | 'numComments' => 1, 156 | ], 157 | ], $rows); 158 | } 159 | 160 | public function testInvalidComparisonFieldThrowsInvalidArgumentException(): void 161 | { 162 | $this->expectException(InvalidArgumentException::class); 163 | $this->expectExceptionMessage('Unable to find comparison field username'); 164 | 165 | $sorter = new Sorter(['username' => 'asc']); 166 | 167 | $rows = [ 168 | ['email' => 'test1@example.com'], 169 | ['email' => 'test2@example.com'], 170 | ]; 171 | 172 | usort($rows, $sorter); 173 | } 174 | 175 | public function testInvalidOrderThrowsInvalidArgumentException(): void 176 | { 177 | $this->expectException(InvalidArgumentException::class); 178 | $this->expectExceptionMessage('$order value of invalid is not accepted. Only a value of asc or desc is allowed.'); 179 | 180 | $sorter = new Sorter(['username' => 'invalid']); 181 | } 182 | 183 | public function testEmptyOrderByThrowsInvalidArgumentException(): void 184 | { 185 | $this->expectException(InvalidArgumentException::class); 186 | $this->expectExceptionMessage('The Sorter class does not accept an empty $orderBy'); 187 | 188 | $sorter = new Sorter([]); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/Profile.php: -------------------------------------------------------------------------------- 1 | id = $identifier['_id']; 35 | } 36 | 37 | public static function loadMetadata(ClassMetadataInterface $metadata): void 38 | { 39 | $metadata->setIdentifier(['_id']); 40 | $metadata->setIdentifierFieldNames(['id']); 41 | $metadata->mapField([ 42 | 'name' => '_id', 43 | 'fieldName' => 'id', 44 | ]); 45 | $metadata->mapField(['fieldName' => 'name']); 46 | $metadata->mapField(['fieldName' => 'address']); 47 | } 48 | 49 | public function getId(): int 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function setId(int $id): void 55 | { 56 | if ($this->id === $id) { 57 | return; 58 | } 59 | 60 | $this->onPropertyChanged('id', $this->id, $id); 61 | $this->id = $id; 62 | } 63 | 64 | public function getName(): string 65 | { 66 | return $this->name; 67 | } 68 | 69 | public function setName(string $name): void 70 | { 71 | if ($this->name === $name) { 72 | return; 73 | } 74 | 75 | $this->onPropertyChanged('name', $this->name, $name); 76 | $this->name = $name; 77 | } 78 | 79 | public function getAddress(): Address 80 | { 81 | if (is_callable($this->address)) { 82 | $this->address = call_user_func($this->address); 83 | } 84 | 85 | return $this->address; 86 | } 87 | 88 | public function setAddress(Address $address): void 89 | { 90 | if ($this->address === $address) { 91 | return; 92 | } 93 | 94 | $this->onPropertyChanged('address', $this->address, $address); 95 | $this->address = $address; 96 | } 97 | 98 | public function addressChanged(string $propName, mixed $oldValue, mixed $newValue): void 99 | { 100 | $this->onPropertyChanged('address', $this->address, $this->address); 101 | } 102 | 103 | /** 104 | * @see HydratableInterface 105 | * 106 | * @param mixed[] $data 107 | */ 108 | public function hydrate(array $data, ObjectManagerInterface $objectManager): void 109 | { 110 | if (isset($data['_id'])) { 111 | $this->id = $data['_id']; 112 | } 113 | 114 | if (isset($data['name'])) { 115 | $this->name = $data['name']; 116 | } 117 | 118 | if ( 119 | ! isset($data['address1']) 120 | && ! isset($data['address2']) 121 | && ! isset($data['city']) 122 | && ! isset($data['state']) 123 | && ! isset($data['zip']) 124 | ) { 125 | return; 126 | } 127 | 128 | $profile = $this; 129 | $this->address = static function () use ($data, $profile): Address { 130 | $address = new Address($profile); 131 | 132 | if (isset($data['address1'])) { 133 | $address->setAddress1($data['address1']); 134 | } 135 | 136 | if (isset($data['address2'])) { 137 | $address->setAddress2($data['address2']); 138 | } 139 | 140 | if (isset($data['city'])) { 141 | $address->setCity($data['city']); 142 | } 143 | 144 | if (isset($data['state'])) { 145 | $address->setState($data['state']); 146 | } 147 | 148 | if (isset($data['zip'])) { 149 | $address->setZip($data['zip']); 150 | } 151 | 152 | return $address; 153 | }; 154 | } 155 | 156 | /** 157 | * @see PersistableInterface 158 | * 159 | * @return mixed[] 160 | */ 161 | public function prepareUpdateChangeSet(ChangeSet $changeSet): array 162 | { 163 | $changeSet = array_map(static function (Change $change) { 164 | return $change->getNewValue(); 165 | }, $changeSet->getChanges()); 166 | 167 | $changeSet['_id'] = $this->id; 168 | 169 | $address = $changeSet['address']; 170 | 171 | if ($address !== null) { 172 | unset($changeSet['address']); 173 | 174 | $changeSet['address1'] = $address->getAddress1(); 175 | $changeSet['address2'] = $address->getAddress2(); 176 | $changeSet['city'] = $address->getCity(); 177 | $changeSet['state'] = $address->getState(); 178 | $changeSet['zip'] = $address->getZip(); 179 | } 180 | 181 | return $changeSet; 182 | } 183 | 184 | /** 185 | * @see PersistableInterface 186 | * 187 | * @return mixed[] 188 | */ 189 | public function preparePersistChangeSet(): array 190 | { 191 | $changeSet = [ 192 | 'name' => $this->name, 193 | ]; 194 | 195 | if ($this->address !== null) { 196 | $address = $this->getAddress(); 197 | 198 | $changeSet['address1'] = $address->getAddress1(); 199 | $changeSet['address2'] = $address->getAddress2(); 200 | $changeSet['city'] = $address->getCity(); 201 | $changeSet['state'] = $address->getState(); 202 | $changeSet['zip'] = $address->getZip(); 203 | } 204 | 205 | if ($this->id !== null) { 206 | $changeSet['_id'] = $this->id; 207 | } 208 | 209 | return $changeSet; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/ObjectRepository/ObjectRepositoryTest.php: -------------------------------------------------------------------------------- 1 | |MockObject */ 34 | private $classMetadata; 35 | 36 | /** @var TestObjectRepository */ 37 | private TestObjectRepository $repository; 38 | 39 | public function testGetClassName(): void 40 | { 41 | self::assertEquals(ArrayObject::class, $this->repository->getClassName()); 42 | } 43 | 44 | public function testFind(): void 45 | { 46 | $data = ['username' => 'jwage']; 47 | 48 | $this->objectDataRepository->expects(self::once()) 49 | ->method('find') 50 | ->with(1) 51 | ->will(self::returnValue($data)); 52 | 53 | $this->objectManager->expects(self::once()) 54 | ->method('getOrCreateObject') 55 | ->with(ArrayObject::class, $data) 56 | ->will(self::returnValue(new stdClass())); 57 | 58 | $object = $this->repository->find(1); 59 | self::assertEquals(new stdClass(), $object); 60 | } 61 | 62 | public function testFindAll(): void 63 | { 64 | $data = [ 65 | ['username' => 'jwage'], 66 | ['username' => 'romanb'], 67 | ]; 68 | 69 | $object1 = new stdClass(); 70 | $object2 = new stdClass(); 71 | 72 | $this->objectDataRepository->expects(self::once()) 73 | ->method('findAll') 74 | ->will(self::returnValue($data)); 75 | 76 | $this->objectManager->expects(self::exactly(2)) 77 | ->method('getOrCreateObject') 78 | ->willReturnMap([ 79 | [ArrayObject::class, $data[0], $object1], 80 | [ArrayObject::class, $data[1], $object2], 81 | ]); 82 | 83 | $objects = $this->repository->findAll(); 84 | self::assertSame([$object1, $object2], $objects); 85 | } 86 | 87 | public function testFindBy(): void 88 | { 89 | $data = [ 90 | ['username' => 'jwage'], 91 | ['username' => 'romanb'], 92 | ]; 93 | 94 | $object1 = new stdClass(); 95 | $object2 = new stdClass(); 96 | 97 | $this->objectDataRepository->expects(self::once()) 98 | ->method('findBy') 99 | ->with([]) 100 | ->will(self::returnValue($data)); 101 | 102 | $this->objectManager->expects(self::exactly(2)) 103 | ->method('getOrCreateObject') 104 | ->willReturnMap([ 105 | [ArrayObject::class, $data[0], $object1], 106 | [ArrayObject::class, $data[1], $object2], 107 | ]); 108 | 109 | $objects = $this->repository->findBy([]); 110 | self::assertSame([$object1, $object2], $objects); 111 | } 112 | 113 | public function testFindOneBy(): void 114 | { 115 | $data = ['username' => 'jwage']; 116 | $criteria = ['username' => 'jwage']; 117 | 118 | $this->objectDataRepository->expects(self::once()) 119 | ->method('findOneBy') 120 | ->with($criteria) 121 | ->will(self::returnValue($data)); 122 | 123 | $this->objectManager->expects(self::once()) 124 | ->method('getOrCreateObject') 125 | ->with(ArrayObject::class, $data) 126 | ->will(self::returnValue(new stdClass())); 127 | 128 | $object = $this->repository->findOneBy($criteria); 129 | self::assertEquals(new stdClass(), $object); 130 | } 131 | 132 | public function testRefresh(): void 133 | { 134 | $data = ['username' => 'jwage']; 135 | 136 | $this->objectDataRepository->expects(self::once()) 137 | ->method('find') 138 | ->with(['id' => 1]) 139 | ->will(self::returnValue($data)); 140 | 141 | $object = new User(); 142 | 143 | $this->hydrator->expects(self::once()) 144 | ->method('hydrate') 145 | ->with($object, $data); 146 | 147 | $this->repository->refresh($object); 148 | } 149 | 150 | public function testCreate(): void 151 | { 152 | $object = new stdClass(); 153 | 154 | $this->objectFactory->expects(self::once()) 155 | ->method('create') 156 | ->with('stdClass') 157 | ->will(self::returnValue($object)); 158 | 159 | self::assertSame($object, $this->repository->create('stdClass')); 160 | } 161 | 162 | protected function setUp(): void 163 | { 164 | $this->objectManager = $this->createMock(ObjectManagerInterface::class); 165 | 166 | $this->objectDataRepository = $this->createMock(ObjectDataRepositoryInterface::class); 167 | 168 | $this->objectFactory = $this->createMock(ObjectFactory::class); 169 | 170 | $this->hydrator = $this->createMock(ObjectHydratorInterface::class); 171 | 172 | $this->eventManager = $this->createMock(EventManager::class); 173 | 174 | $this->classMetadata = $this->createMock(ClassMetadataInterface::class); 175 | 176 | $this->objectManager->expects(self::once()) 177 | ->method('getClassMetadata') 178 | ->with(ArrayObject::class) 179 | ->will(self::returnValue($this->classMetadata)); 180 | 181 | $this->repository = new TestObjectRepository( 182 | $this->objectManager, 183 | $this->objectDataRepository, 184 | $this->objectFactory, 185 | $this->hydrator, 186 | $this->eventManager, 187 | ArrayObject::class, 188 | ); 189 | } 190 | } 191 | 192 | /** 193 | * @template T of object 194 | * @template-implements ObjectRepository 195 | */ 196 | class TestObjectRepository extends ObjectRepository 197 | { 198 | /** @return ClassMetadataInterface */ 199 | public function getClassMetadata(): ClassMetadataInterface 200 | { 201 | return $this->class; 202 | } 203 | 204 | /** @return int[] */ 205 | public function getObjectIdentifier(object $object): array 206 | { 207 | return ['id' => 1]; 208 | } 209 | 210 | /** 211 | * @param mixed[] $data 212 | * 213 | * @return int[] 214 | */ 215 | public function getObjectIdentifierFromData(array $data): array 216 | { 217 | return ['id' => 1]; 218 | } 219 | 220 | public function merge(object $object): void 221 | { 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Model/User.php: -------------------------------------------------------------------------------- 1 | */ 35 | private Collection $groups; 36 | 37 | /** @var string[] */ 38 | public array $called = []; 39 | 40 | public function __construct() 41 | { 42 | $this->groups = new ArrayCollection(); 43 | } 44 | 45 | /** 46 | * Assign identifier to object. 47 | * 48 | * @param mixed[] $identifier 49 | */ 50 | public function assignIdentifier(array $identifier): void 51 | { 52 | $this->id = $identifier['_id']; 53 | } 54 | 55 | public static function loadMetadata(ClassMetadataInterface $metadata): void 56 | { 57 | $metadata->setIdentifier(['_id']); 58 | $metadata->setIdentifierFieldNames(['id']); 59 | $metadata->mapField([ 60 | 'name' => '_id', 61 | 'fieldName' => 'id', 62 | ]); 63 | $metadata->mapField(['fieldName' => 'username']); 64 | $metadata->mapField(['fieldName' => 'password']); 65 | $metadata->mapField([ 66 | 'name' => 'profileId', 67 | 'fieldName' => 'profile', 68 | ]); 69 | $metadata->mapField([ 70 | 'name' => 'groupIds', 71 | 'fieldName' => 'groups', 72 | ]); 73 | } 74 | 75 | public function getId(): int|null 76 | { 77 | return $this->id; 78 | } 79 | 80 | public function setId(int $id): void 81 | { 82 | if ($this->id === $id) { 83 | return; 84 | } 85 | 86 | $this->onPropertyChanged('id', $this->id, $id); 87 | $this->id = $id; 88 | } 89 | 90 | public function getUsername(): string 91 | { 92 | return $this->username; 93 | } 94 | 95 | public function setUsername(string $username): void 96 | { 97 | if ($this->username === $username) { 98 | return; 99 | } 100 | 101 | $this->onPropertyChanged('username', $this->username, $username); 102 | $this->username = $username; 103 | } 104 | 105 | public function getPassword(): string 106 | { 107 | return $this->password; 108 | } 109 | 110 | public function setPassword(string $password): void 111 | { 112 | if ($this->password === $password) { 113 | return; 114 | } 115 | 116 | $this->onPropertyChanged('password', $this->password, $password); 117 | $this->password = $password; 118 | } 119 | 120 | public function getProfile(): Profile 121 | { 122 | if (is_callable($this->profile)) { 123 | $this->profile = call_user_func($this->profile); 124 | } 125 | 126 | return $this->profile; 127 | } 128 | 129 | public function setProfile(Profile $profile): void 130 | { 131 | if ($this->profile === $profile) { 132 | return; 133 | } 134 | 135 | $this->onPropertyChanged('profile', $this->profile, $profile); 136 | $this->profile = $profile; 137 | } 138 | 139 | public function addGroup(Group $group): void 140 | { 141 | $this->groups->add($group); 142 | $this->onPropertyChanged('groups', $this->groups, $this->groups); 143 | } 144 | 145 | /** @return Collection */ 146 | public function getGroups(): Collection 147 | { 148 | return $this->groups; 149 | } 150 | 151 | /** @param mixed[] $arguments */ 152 | public function __call(string $method, array $arguments): void 153 | { 154 | $this->called[] = $method; 155 | } 156 | 157 | /** 158 | * @see HydratableInterface 159 | * 160 | * @param mixed[] $data 161 | */ 162 | public function hydrate(array $data, ObjectManagerInterface $objectManager): void 163 | { 164 | if (isset($data['_id'])) { 165 | $this->id = $data['_id']; 166 | } 167 | 168 | if (isset($data['username'])) { 169 | $this->username = $data['username']; 170 | } 171 | 172 | if (isset($data['password'])) { 173 | $this->password = $data['password']; 174 | } 175 | 176 | if (isset($data['profileId']) && isset($data['profileName'])) { 177 | $profileData = [ 178 | '_id' => $data['profileId'], 179 | 'name' => $data['profileName'], 180 | ]; 181 | 182 | $this->profile = static function () use ($objectManager, $profileData) { 183 | return $objectManager->getOrCreateObject( 184 | 'Doctrine\SkeletonMapper\Tests\Model\Profile', 185 | $profileData, 186 | ); 187 | }; 188 | } elseif (isset($data['profileId'])) { 189 | $this->profile = static function () use ($objectManager, $data) { 190 | return $objectManager->find( 191 | Profile::class, 192 | $data['profileId'], 193 | ); 194 | }; 195 | } 196 | 197 | if (! isset($data['groupIds'])) { 198 | return; 199 | } 200 | 201 | $this->groups = new LazyCollection(static function () use ($objectManager, $data): ArrayCollection { 202 | return new ArrayCollection(array_map(static function (string $groupId) use ($objectManager): object|null { 203 | return $objectManager->find( 204 | Group::class, 205 | (int) $groupId, 206 | ); 207 | }, explode(',', $data['groupIds']))); 208 | }); 209 | } 210 | 211 | /** 212 | * @see PersistableInterface 213 | * 214 | * @return mixed[] 215 | */ 216 | public function preparePersistChangeSet(): array 217 | { 218 | $changeSet = [ 219 | 'username' => $this->username, 220 | 'password' => $this->password, 221 | ]; 222 | 223 | if ($this->profile !== null) { 224 | $changeSet['profileId'] = $this->getProfile()->getId(); 225 | } 226 | 227 | $groupIds = $this->groups->map(static function (Group $group): int { 228 | return $group->getId(); 229 | })->toArray(); 230 | 231 | $changeSet['groupIds'] = implode(',', $groupIds); 232 | 233 | if ($this->id !== null) { 234 | $changeSet['_id'] = $this->id; 235 | } 236 | 237 | return $changeSet; 238 | } 239 | 240 | /** 241 | * @see PersistableInterface 242 | * 243 | * @return mixed[] 244 | */ 245 | public function prepareUpdateChangeSet(ChangeSet $changeSet): array 246 | { 247 | $changeSet = array_map(static function (Change $change) { 248 | return $change->getNewValue(); 249 | }, $changeSet->getChanges()); 250 | 251 | $changeSet['_id'] = $this->id; 252 | 253 | if (isset($changeSet['profile'])) { 254 | $changeSet['profileId'] = $changeSet['profile']->getId(); 255 | unset($changeSet['profile']); 256 | } 257 | 258 | if (isset($changeSet['groups'])) { 259 | $groupIds = $changeSet['groups']->map(static function (Group $group): int { 260 | return $group->getId(); 261 | })->toArray(); 262 | 263 | $changeSet['groupIds'] = implode(',', $groupIds); 264 | unset($changeSet['groups']); 265 | } 266 | 267 | return $changeSet; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/UnitOfWork.php: -------------------------------------------------------------------------------- 1 | $objectPersisterFactory */ 44 | public function __construct( 45 | private ObjectManagerInterface $objectManager, 46 | private ObjectPersisterFactoryInterface $objectPersisterFactory, 47 | private ObjectIdentityMap $objectIdentityMap, 48 | EventManager $eventManager, 49 | ) { 50 | $this->eventDispatcher = new EventDispatcher( 51 | $objectManager, 52 | $eventManager, 53 | ); 54 | $this->persister = new Persister( 55 | $this, 56 | $this->eventDispatcher, 57 | $this->objectIdentityMap, 58 | ); 59 | 60 | $this->objectChangeSets = new ChangeSets(); 61 | } 62 | 63 | public function merge(object $object): void 64 | { 65 | $this->getObjectRepository($object)->merge($object); 66 | } 67 | 68 | public function persist(object $object): void 69 | { 70 | if ($this->isScheduledForPersist($object)) { 71 | throw new InvalidArgumentException('Object is already scheduled for persist.'); 72 | } 73 | 74 | $this->eventDispatcher->dispatchPrePersist($object); 75 | 76 | $this->objectsToPersist[spl_object_hash($object)] = $object; 77 | 78 | if (! ($object instanceof NotifyPropertyChanged)) { 79 | return; 80 | } 81 | 82 | $object->addPropertyChangedListener($this); 83 | } 84 | 85 | /** @param object $object The instance to update */ 86 | public function update(object $object): void 87 | { 88 | if ($this->isScheduledForUpdate($object)) { 89 | throw new InvalidArgumentException('Object is already scheduled for update.'); 90 | } 91 | 92 | $this->eventDispatcher->dispatchPreUpdate( 93 | $object, 94 | $this->getObjectChangeSet($object), 95 | ); 96 | 97 | $this->objectsToUpdate[spl_object_hash($object)] = $object; 98 | } 99 | 100 | /** @param object $object The object instance to remove. */ 101 | public function remove(object $object): void 102 | { 103 | if ($this->isScheduledForRemove($object)) { 104 | throw new InvalidArgumentException('Object is already scheduled for remove.'); 105 | } 106 | 107 | $this->eventDispatcher->dispatchPreRemove($object); 108 | 109 | $this->objectsToRemove[spl_object_hash($object)] = $object; 110 | } 111 | 112 | public function clear(string|null $objectName = null): void 113 | { 114 | $this->objectIdentityMap->clear($objectName); 115 | 116 | $this->objectsToPersist = []; 117 | $this->objectsToUpdate = []; 118 | $this->objectsToRemove = []; 119 | $this->objectChangeSets = new ChangeSets(); 120 | 121 | $this->eventDispatcher->dispatchOnClearEvent($objectName); 122 | } 123 | 124 | public function detach(object $object): void 125 | { 126 | $this->objectIdentityMap->detach($object); 127 | } 128 | 129 | public function refresh(object $object): void 130 | { 131 | $this->getObjectRepository($object)->refresh($object); 132 | } 133 | 134 | public function contains(object $object): bool 135 | { 136 | return $this->objectIdentityMap->contains($object) 137 | || $this->isScheduledForPersist($object); 138 | } 139 | 140 | /** 141 | * Commit the contents of the unit of work. 142 | */ 143 | public function commit(): void 144 | { 145 | $this->eventDispatcher->dispatchPreFlush(); 146 | 147 | if ( 148 | $this->objectsToPersist === [] && 149 | $this->objectsToUpdate === [] && 150 | $this->objectsToRemove === [] 151 | ) { 152 | return; // Nothing to do. 153 | } 154 | 155 | $objects = array_merge( 156 | $this->objectsToPersist, 157 | $this->objectsToUpdate, 158 | $this->objectsToRemove, 159 | ); 160 | $this->eventDispatcher->dispatchPreFlushLifecycleCallbacks($objects); 161 | 162 | $this->eventDispatcher->dispatchOnFlush(); 163 | 164 | $this->persister->executePersists(); 165 | $this->persister->executeUpdates(); 166 | $this->persister->executeRemoves(); 167 | 168 | $this->eventDispatcher->dispatchPostFlush(); 169 | 170 | $this->objectsToPersist = []; 171 | $this->objectsToUpdate = []; 172 | $this->objectsToRemove = []; 173 | $this->objectChangeSets = new ChangeSets(); 174 | } 175 | 176 | public function isScheduledForPersist(object $object): bool 177 | { 178 | return isset($this->objectsToPersist[spl_object_hash($object)]); 179 | } 180 | 181 | /** @return object[] */ 182 | public function getObjectsToPersist(): array 183 | { 184 | return $this->objectsToPersist; 185 | } 186 | 187 | public function isScheduledForUpdate(object $object): bool 188 | { 189 | return isset($this->objectsToUpdate[spl_object_hash($object)]); 190 | } 191 | 192 | /** @return object[] */ 193 | public function getObjectsToUpdate(): array 194 | { 195 | return $this->objectsToUpdate; 196 | } 197 | 198 | public function isScheduledForRemove(object $object): bool 199 | { 200 | return isset($this->objectsToRemove[spl_object_hash($object)]); 201 | } 202 | 203 | /** @return object[] */ 204 | public function getObjectsToRemove(): array 205 | { 206 | return $this->objectsToRemove; 207 | } 208 | 209 | /* PropertyChangedListener implementation */ 210 | 211 | /** 212 | * Notifies this UnitOfWork of a property change in an object. 213 | * 214 | * @param object $object The entity that owns the property. 215 | * @param string $propertyName The name of the property that changed. 216 | * @param mixed $oldValue The old value of the property. 217 | * @param mixed $newValue The new value of the property. 218 | */ 219 | public function propertyChanged(object $object, string $propertyName, mixed $oldValue, mixed $newValue): void 220 | { 221 | if (! $this->isInIdentityMap($object)) { 222 | return; 223 | } 224 | 225 | if (! $this->isScheduledForUpdate($object)) { 226 | $this->update($object); 227 | } 228 | 229 | $this->objectChangeSets->addObjectChange( 230 | $object, 231 | new Change($propertyName, $oldValue, $newValue), 232 | ); 233 | } 234 | 235 | /** 236 | * Gets the changeset for a object. 237 | */ 238 | public function getObjectChangeSet(object $object): ChangeSet 239 | { 240 | return $this->objectChangeSets->getObjectChangeSet($object); 241 | } 242 | 243 | /** 244 | * Checks whether an object is registered in the identity map of this UnitOfWork. 245 | */ 246 | public function isInIdentityMap(object $object): bool 247 | { 248 | return $this->objectIdentityMap->contains($object); 249 | } 250 | 251 | /** 252 | * @param mixed[] $data 253 | * @phpstan-param class-string $className 254 | */ 255 | public function getOrCreateObject(string $className, array $data): object 256 | { 257 | $object = $this->objectIdentityMap->tryGetById($className, $data); 258 | 259 | if ($object !== null) { 260 | return $object; 261 | } 262 | 263 | return $this->createObject($className, $data); 264 | } 265 | 266 | /** @phpstan-return ObjectPersisterInterface */ 267 | public function getObjectPersister(object $object): ObjectPersisterInterface 268 | { 269 | return $this->objectPersisterFactory 270 | ->getPersister($object::class); 271 | } 272 | 273 | /** @return ObjectRepositoryInterface */ 274 | public function getObjectRepository(object $object): ObjectRepositoryInterface 275 | { 276 | return $this->objectManager 277 | ->getRepository($object::class); 278 | } 279 | 280 | /** 281 | * @param mixed[] $data 282 | * @phpstan-param class-string $className 283 | */ 284 | private function createObject(string $className, array $data): object 285 | { 286 | $repository = $this->objectManager->getRepository($className); 287 | 288 | $object = $repository->create($className); 289 | 290 | if ($object instanceof NotifyPropertyChanged) { 291 | $object->addPropertyChangedListener($this); 292 | } 293 | 294 | $this->eventDispatcher->dispatchPreLoad($object, $data); 295 | 296 | $repository->hydrate($object, $data); 297 | 298 | $this->eventDispatcher->dispatchPostLoad($object); 299 | 300 | $this->objectIdentityMap->addToIdentityMap($object, $data); 301 | 302 | return $object; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /tests/Doctrine/SkeletonMapper/Tests/Mapping/ClassMetadataTest.php: -------------------------------------------------------------------------------- 1 | */ 17 | private ClassMetadata $class; 18 | 19 | public function testMapField(): void 20 | { 21 | $this->class->mapField(['fieldName' => 'name']); 22 | 23 | self::assertEquals(['name' => ['fieldName' => 'name', 'name' => 'name']], $this->class->fieldMappings); 24 | } 25 | 26 | public function testGetName(): void 27 | { 28 | self::assertEquals(ClassMetadataTestModel::class, $this->class->getName()); 29 | } 30 | 31 | public function testGetIdentifier(): void 32 | { 33 | $this->class->identifier = ['id']; 34 | self::assertEquals(['id'], $this->class->getIdentifier()); 35 | } 36 | 37 | public function testGetReflectionClass(): void 38 | { 39 | self::assertSame(ClassMetadataTestModel::class, $this->class->getReflectionClass()->getName()); 40 | } 41 | 42 | public function testIsIdentifier(): void 43 | { 44 | self::assertFalse($this->class->isIdentifier('id')); 45 | 46 | $this->class->identifierFieldNames = ['id']; 47 | 48 | self::assertTrue($this->class->isIdentifier('id')); 49 | } 50 | 51 | public function testHasField(): void 52 | { 53 | self::assertFalse($this->class->hasField('username')); 54 | 55 | $this->class->mapField(['fieldName' => 'username']); 56 | 57 | self::assertTrue($this->class->hasField('username')); 58 | } 59 | 60 | public function testGetFieldNames(): void 61 | { 62 | $this->class->mapField(['fieldName' => 'username']); 63 | self::assertEquals(['username'], $this->class->getFieldNames()); 64 | } 65 | 66 | public function testGetAssociationNames(): void 67 | { 68 | self::assertEquals([], $this->class->getAssociationNames()); 69 | } 70 | 71 | public function testGetTypeOfField(): void 72 | { 73 | self::assertEquals('', $this->class->getTypeOfField('username')); 74 | 75 | $this->class->mapField(['fieldName' => 'username', 'type' => 'string']); 76 | 77 | self::assertEquals('string', $this->class->getTypeOfField('username')); 78 | } 79 | 80 | public function testGetAssociationTargetClass(): void 81 | { 82 | $this->class->mapField([ 83 | 'fieldName' => 'groups', 84 | 'targetObject' => 'Test', 85 | 'type' => 'many', 86 | ]); 87 | self::assertEquals('Test', $this->class->getAssociationTargetClass('groups')); 88 | } 89 | 90 | public function testGetAssociationTargetClassThrowsInvalidArgumentException(): void 91 | { 92 | $this->expectException(InvalidArgumentException::class); 93 | $this->expectExceptionMessage("Association name expected, 'groups' is not an association."); 94 | 95 | $this->class->getAssociationTargetClass('groups'); 96 | } 97 | 98 | public function testGetIdentifierValues(): void 99 | { 100 | $this->class->identifier = ['id']; 101 | $this->class->identifierFieldNames = ['id']; 102 | $this->class->mapField(['fieldName' => 'id']); 103 | $this->class->mapField(['fieldName' => 'username']); 104 | 105 | $object = new ClassMetadataTestModel(); 106 | $object->id = 1; 107 | 108 | self::assertEquals(['id' => 1], $this->class->getIdentifierValues($object)); 109 | } 110 | 111 | public function testHasAssociation(): void 112 | { 113 | self::assertFalse($this->class->hasAssociation('groups')); 114 | 115 | $this->class->mapField([ 116 | 'fieldName' => 'groups', 117 | 'targetObject' => 'Test', 118 | 'type' => 'many', 119 | ]); 120 | 121 | self::assertTrue($this->class->hasAssociation('groups')); 122 | } 123 | 124 | public function testAddingAssociationMappingDoesNotAddFieldMapping(): void 125 | { 126 | self::assertFalse($this->class->hasAssociation('groups')); 127 | 128 | $this->class->mapField( 129 | [ 130 | 'fieldName' => 'groups', 131 | 'targetObject' => 'Test', 132 | 'type' => 'many', 133 | ], 134 | ); 135 | 136 | self::assertFalse($this->class->hasField('groups')); 137 | } 138 | 139 | public function testIsSingleValuedAssociation(): void 140 | { 141 | self::assertFalse($this->class->isSingleValuedAssociation('groups')); 142 | 143 | $this->class->mapField([ 144 | 'fieldName' => 'groups', 145 | 'targetObject' => 'Test', 146 | 'type' => 'many', 147 | ]); 148 | 149 | self::assertFalse($this->class->isSingleValuedAssociation('groups')); 150 | 151 | $this->class->mapField([ 152 | 'fieldName' => 'profile', 153 | 'targetObject' => 'Test', 154 | 'type' => 'one', 155 | ]); 156 | 157 | self::assertTrue($this->class->isSingleValuedAssociation('profile')); 158 | } 159 | 160 | public function testIsCollectionValuedAssociation(): void 161 | { 162 | self::assertFalse($this->class->isCollectionValuedAssociation('profile')); 163 | 164 | $this->class->mapField([ 165 | 'fieldName' => 'groups', 166 | 'targetObject' => 'Test', 167 | 'type' => 'many', 168 | ]); 169 | 170 | self::assertTrue($this->class->isCollectionValuedAssociation('groups')); 171 | 172 | $this->class->mapField([ 173 | 'fieldName' => 'profile', 174 | 'targetObject' => 'Test', 175 | 'type' => 'one', 176 | ]); 177 | 178 | self::assertFalse($this->class->isCollectionValuedAssociation('profile')); 179 | } 180 | 181 | public function testInvokeLifecycleCallbacksWithArguments(): void 182 | { 183 | $object = new ClassMetadataTestModel(); 184 | $data = ['test']; 185 | 186 | $this->class->lifecycleCallbacks['test'] = ['testEvent']; 187 | 188 | $this->class->invokeLifecycleCallbacks('test', $object, [$data]); 189 | 190 | self::assertEquals($data, $object->testEventCalled); 191 | } 192 | 193 | public function testInvokeLifecycleCallbacksThrowsInvalidArgumentException(): void 194 | { 195 | $this->expectException(InvalidArgumentException::class); 196 | $this->expectExceptionMessage('Expected class "Doctrine\SkeletonMapper\Tests\Mapping\ClassMetadataTestModel"; found: "stdClass"'); 197 | 198 | $this->class->invokeLifecycleCallbacks('test', new stdClass()); 199 | } 200 | 201 | public function testInvokeLifecycleCallbacksWithoutArguments(): void 202 | { 203 | $object = new ClassMetadataTestModel(); 204 | 205 | $this->class->lifecycleCallbacks['test'] = ['testEvent']; 206 | 207 | $this->class->invokeLifecycleCallbacks('test', $object); 208 | 209 | self::assertTrue($object->testEventCalled); 210 | } 211 | 212 | public function testHasLifecycleCallbacks(): void 213 | { 214 | self::assertFalse($this->class->hasLifecycleCallbacks('test')); 215 | 216 | $this->class->lifecycleCallbacks['test'] = ['testEvent']; 217 | 218 | self::assertTrue($this->class->hasLifecycleCallbacks('test')); 219 | } 220 | 221 | public function testGetLifecycleCallbacks(): void 222 | { 223 | self::assertEquals([], $this->class->getLifecycleCallbacks('test')); 224 | 225 | $this->class->lifecycleCallbacks['test'] = ['testEvent']; 226 | 227 | self::assertEquals(['testEvent'], $this->class->getLifecycleCallbacks('test')); 228 | } 229 | 230 | public function testAddLifecycleCallback(): void 231 | { 232 | self::assertFalse($this->class->hasLifecycleCallbacks('test')); 233 | 234 | $this->class->addLifecycleCallback('testEvent', 'test'); 235 | $this->class->addLifecycleCallback('testEvent', 'test'); 236 | 237 | self::assertTrue($this->class->hasLifecycleCallbacks('test')); 238 | self::assertCount(1, $this->class->lifecycleCallbacks['test']); 239 | } 240 | 241 | public function testSetLifecycleCallbacks(): void 242 | { 243 | self::assertFalse($this->class->hasLifecycleCallbacks('test')); 244 | 245 | $this->class->setLifecycleCallbacks(['test' => ['testEvent']]); 246 | 247 | self::assertTrue($this->class->hasLifecycleCallbacks('test')); 248 | } 249 | 250 | public function testGetIdentifierFieldNames(): void 251 | { 252 | $this->class->identifierFieldNames = ['id']; 253 | self::assertEquals(['id'], $this->class->getIdentifierFieldNames()); 254 | } 255 | 256 | public function testGetAssociationMappedByTargetField(): void 257 | { 258 | $this->expectException(BadMethodCallException::class); 259 | $this->expectExceptionMessage('Doctrine\SkeletonMapper\Mapping\ClassMetadata::getAssociationMappedByTargetField() is not implemented yet.'); 260 | 261 | $this->class->getAssociationMappedByTargetField('test'); 262 | } 263 | 264 | public function testIsAssociationInverseSide(): void 265 | { 266 | $this->expectException(BadMethodCallException::class); 267 | $this->expectExceptionMessage('Doctrine\SkeletonMapper\Mapping\ClassMetadata::isAssociationInverseSide() is not implemented yet.'); 268 | 269 | $this->class->isAssociationInverseSide('test'); 270 | } 271 | 272 | protected function setUp(): void 273 | { 274 | $this->class = new ClassMetadata(ClassMetadataTestModel::class); 275 | } 276 | } 277 | 278 | class ClassMetadataTestModel 279 | { 280 | public int $id; 281 | 282 | public string $name; 283 | 284 | /** @var array|true */ 285 | public array|bool $testEventCalled; 286 | 287 | /** @param array|true $args */ 288 | public function testEvent(array|bool|null $args = null): void 289 | { 290 | if ($args !== null) { 291 | $this->testEventCalled = $args; 292 | } else { 293 | $this->testEventCalled = true; 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /lib/Doctrine/SkeletonMapper/Mapping/ClassMetadata.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | class ClassMetadata implements ClassMetadataInterface 27 | { 28 | /** @var class-string */ 29 | public $name; 30 | 31 | /** @var mixed[] */ 32 | public array $identifier = []; 33 | 34 | /** @var string[] */ 35 | public array $identifierFieldNames = []; 36 | 37 | /** @var string[][] */ 38 | public array $fieldMappings = []; 39 | 40 | /** var array */ 41 | /** @var mixed[][] */ 42 | public array $associationMappings = []; 43 | 44 | /** @var string[][] */ 45 | public array $lifecycleCallbacks = []; 46 | 47 | /** @var ReflectionClass */ 48 | public ReflectionClass $reflClass; 49 | 50 | /** @var ReflectionProperty[] */ 51 | public array $reflFields = []; 52 | 53 | /** @phpstan-param class-string $className */ 54 | public function __construct(string $className) 55 | { 56 | $this->name = $className; 57 | $this->reflClass = new ReflectionClass($className); 58 | } 59 | 60 | /** @param mixed[] $identifier */ 61 | public function setIdentifier(array $identifier): void 62 | { 63 | $this->identifier = $identifier; 64 | } 65 | 66 | /** @param string[] $identifierFieldNames */ 67 | public function setIdentifierFieldNames(array $identifierFieldNames): void 68 | { 69 | $this->identifierFieldNames = $identifierFieldNames; 70 | } 71 | 72 | /** 73 | * {@inheritDoc} 74 | */ 75 | public function mapField(array $mapping): void 76 | { 77 | if (! isset($mapping['name'])) { 78 | $mapping['name'] = $mapping['fieldName']; 79 | } 80 | 81 | if (isset($mapping['type']) && isset($mapping['targetObject'])) { 82 | $this->associationMappings[$mapping['fieldName']] = $mapping; 83 | } else { 84 | $this->fieldMappings[$mapping['fieldName']] = $mapping; 85 | } 86 | 87 | $this->initReflField($mapping); 88 | } 89 | 90 | /** 91 | * Gets the fully-qualified class name of this persistent class. 92 | */ 93 | public function getName(): string 94 | { 95 | return $this->name; 96 | } 97 | 98 | /** 99 | * Gets the mapped identifier field name. 100 | * 101 | * The returned structure is an array of the identifier field names. 102 | * 103 | * {@inheritDoc} 104 | */ 105 | public function getIdentifier(): array 106 | { 107 | return $this->identifier; 108 | } 109 | 110 | /** 111 | * Gets the ReflectionClass instance for this mapped class. 112 | * 113 | * @phpstan-return ReflectionClass 114 | */ 115 | public function getReflectionClass(): ReflectionClass 116 | { 117 | return $this->reflClass; 118 | } 119 | 120 | public function isIdentifier(string $fieldName): bool 121 | { 122 | return in_array($fieldName, $this->getIdentifierFieldNames(), true); 123 | } 124 | 125 | public function hasField(string $fieldName): bool 126 | { 127 | return isset($this->fieldMappings[$fieldName]); 128 | } 129 | 130 | /** 131 | * A numerically indexed list of field names of this persistent class. 132 | * 133 | * This array includes identifier fields if present on this class. 134 | * 135 | * {@inheritDoc} 136 | */ 137 | public function getFieldNames(): array 138 | { 139 | return array_keys($this->fieldMappings); 140 | } 141 | 142 | /** 143 | * An array of field mappings for this persistent class indexed by field name. 144 | * 145 | * @return mixed[][] 146 | */ 147 | public function getFieldMappings(): array 148 | { 149 | return $this->fieldMappings; 150 | } 151 | 152 | /** 153 | * {@inheritDoc} 154 | */ 155 | public function getAssociationNames(): array 156 | { 157 | return array_keys($this->associationMappings); 158 | } 159 | 160 | public function getTypeOfField(string $fieldName): string 161 | { 162 | return $this->fieldMappings[$fieldName]['type'] ?? ''; 163 | } 164 | 165 | public function getAssociationTargetClass(string $assocName): string|null 166 | { 167 | if (! isset($this->associationMappings[$assocName])) { 168 | throw new InvalidArgumentException( 169 | sprintf("Association name expected, '%s' is not an association.", $assocName), 170 | ); 171 | } 172 | 173 | return $this->associationMappings[$assocName]['targetObject']; 174 | } 175 | 176 | /** 177 | * {@inheritDoc} 178 | */ 179 | public function getIdentifierValues(object $object): array 180 | { 181 | $identifier = []; 182 | foreach ($this->identifierFieldNames as $identifierFieldName) { 183 | $identifier[$this->fieldMappings[$identifierFieldName]['name']] = $this->reflFields[$identifierFieldName]->getValue($object); 184 | } 185 | 186 | return $identifier; 187 | } 188 | 189 | /** 190 | * {@inheritDoc} 191 | * 192 | * Checks whether the class has a mapped association (embed or reference) with the given field name. 193 | */ 194 | public function hasAssociation(string $fieldName): bool 195 | { 196 | return isset($this->associationMappings[$fieldName]); 197 | } 198 | 199 | /** 200 | * {@inheritDoc} 201 | * 202 | * Checks whether the class has a mapped reference or embed for the specified field and 203 | * is a single valued association. 204 | */ 205 | public function isSingleValuedAssociation(string $fieldName): bool 206 | { 207 | return isset($this->associationMappings[$fieldName]['type']) && 208 | $this->associationMappings[$fieldName]['type'] === 'one'; 209 | } 210 | 211 | /** 212 | * {@inheritDoc} 213 | * 214 | * Checks whether the class has a mapped reference or embed for the specified field and 215 | * is a collection valued association. 216 | */ 217 | public function isCollectionValuedAssociation(string $fieldName): bool 218 | { 219 | return isset($this->associationMappings[$fieldName]['type']) && 220 | $this->associationMappings[$fieldName]['type'] === 'many'; 221 | } 222 | 223 | public function invokeLifecycleCallbacks(string $event, object $object, array|null $arguments = null): void 224 | { 225 | if (! $object instanceof $this->name) { 226 | throw new InvalidArgumentException( 227 | sprintf('Expected class "%s"; found: "%s"', $this->name, $object::class), 228 | ); 229 | } 230 | 231 | foreach ($this->lifecycleCallbacks[$event] as $callback) { 232 | if ($arguments !== null) { 233 | $callable = [$object, $callback]; 234 | assert(is_callable($callable)); 235 | 236 | call_user_func_array($callable, $arguments); 237 | } else { 238 | $callable = [$object, $callback]; 239 | assert(is_callable($callable)); 240 | 241 | call_user_func($callable); 242 | } 243 | } 244 | } 245 | 246 | /** 247 | * Checks whether the class has callbacks registered for a lifecycle event. 248 | * 249 | * @param string $event Lifecycle event 250 | */ 251 | public function hasLifecycleCallbacks(string $event): bool 252 | { 253 | return isset($this->lifecycleCallbacks[$event]); 254 | } 255 | 256 | /** 257 | * Gets the registered lifecycle callbacks for an event. 258 | * 259 | * @return string[] 260 | */ 261 | public function getLifecycleCallbacks(string $event): array 262 | { 263 | return $this->lifecycleCallbacks[$event] ?? []; 264 | } 265 | 266 | /** 267 | * Adds a lifecycle callback for objects of this class. 268 | * 269 | * If the callback is already registered, this is a NOOP. 270 | */ 271 | public function addLifecycleCallback(string $callback, string $event): void 272 | { 273 | if (isset($this->lifecycleCallbacks[$event]) && in_array($callback, $this->lifecycleCallbacks[$event], true)) { 274 | return; 275 | } 276 | 277 | $this->lifecycleCallbacks[$event][] = $callback; 278 | } 279 | 280 | /** 281 | * Sets the lifecycle callbacks for objects of this class. 282 | * 283 | * Any previously registered callbacks are overwritten. 284 | * 285 | * @param string[][] $callbacks 286 | */ 287 | public function setLifecycleCallbacks(array $callbacks): void 288 | { 289 | $this->lifecycleCallbacks = $callbacks; 290 | } 291 | 292 | /** 293 | * Returns an array of identifier field names numerically indexed. 294 | * 295 | * {@inheritDoc} 296 | */ 297 | public function getIdentifierFieldNames(): array 298 | { 299 | return $this->identifierFieldNames; 300 | } 301 | 302 | /** 303 | * {@inheritDoc} 304 | */ 305 | public function getAssociationMappedByTargetField(string $fieldName) 306 | { 307 | throw new BadMethodCallException(__METHOD__ . '() is not implemented yet.'); 308 | } 309 | 310 | /** 311 | * {@inheritDoc} 312 | */ 313 | public function isAssociationInverseSide(string $fieldName) 314 | { 315 | throw new BadMethodCallException(__METHOD__ . '() is not implemented yet.'); 316 | } 317 | 318 | /** @param mixed[] $mapping */ 319 | private function initReflField(array $mapping): void 320 | { 321 | if (! $this->reflClass->hasProperty($mapping['fieldName'])) { 322 | return; 323 | } 324 | 325 | $reflProp = $this->reflClass->getProperty($mapping['fieldName']); 326 | $reflProp->setAccessible(true); 327 | $this->reflFields[$mapping['fieldName']] = $reflProp; 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /docs/en/index.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | The Doctrine SkeletonMapper is a skeleton object mapper where you are 5 | 100% responsible for implementing the guts of the persistence 6 | operations. This means you write plain old PHP code for the data 7 | repositories, object repositories, object hydrators and object 8 | persisters. 9 | 10 | Installation 11 | ============ 12 | 13 | .. code-block:: console 14 | 15 | composer require doctrine/skeleton-mapper 16 | 17 | Interfaces 18 | ========== 19 | 20 | The ``Doctrine\SkeletonMapper\DataRepository\ObjectDataRepositoryInterface`` interface is responsible for reading the the raw data. 21 | 22 | The ``Doctrine\SkeletonMapper\Hydrator\ObjectHydrator`` interface is responsible for hydrating the raw data to an object: 23 | 24 | The ``Doctrine\SkeletonMapper\ObjectRepository\ObjectRepository`` interface is responsible for reading objects: 25 | 26 | The ``Doctrine\SkeletonMapper\Persister\ObjectPersisterInterface`` interface is responsible for persisting the state of an object to the raw data source: 27 | 28 | Example Implementation 29 | ====================== 30 | 31 | Now lets put it all together with an example implementation: 32 | 33 | Model 34 | ----- 35 | 36 | .. code-block:: php 37 | 38 | class User implements HydratableInterface, IdentifiableInterface, LoadMetadataInterface, NotifyPropertyChanged, PersistableInterface 39 | { 40 | private int|null $id = null; 41 | 42 | private string $username = ''; 43 | 44 | private string $password = ''; 45 | 46 | /** @var PropertyChangedListener[] */ 47 | private array $listeners = []; 48 | 49 | public function getId(): int|null 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function setId(int $id): void 55 | { 56 | $this->onPropertyChanged('id', $this->id, $id); 57 | 58 | $this->id = $id; 59 | } 60 | 61 | public function getUsername(): string 62 | { 63 | return $this->username; 64 | } 65 | 66 | public function setUsername(string $username): void 67 | { 68 | $this->onPropertyChanged('username', $this->username, $username); 69 | 70 | $this->username = $username; 71 | } 72 | 73 | public function getPassword(): string 74 | { 75 | return $this->password; 76 | } 77 | 78 | public function setPassword(string $password): void 79 | { 80 | $this->onPropertyChanged('password', $this->password, $password); 81 | 82 | $this->password = $password; 83 | } 84 | 85 | public function addPropertyChangedListener(PropertyChangedListener $listener): void 86 | { 87 | $this->listeners[] = $listener; 88 | } 89 | 90 | private function onPropertyChanged(string $propName, mixed $oldValue, mixed $newValue): void 91 | { 92 | if ($this->listeners === []) { 93 | return; 94 | } 95 | 96 | foreach ($this->listeners as $listener) { 97 | $listener->propertyChanged($this, $propName, $oldValue, $newValue); 98 | } 99 | } 100 | 101 | public static function loadMetadata(ClassMetadataInterface $metadata): void 102 | { 103 | $metadata->setIdentifier(['id']); 104 | $metadata->setIdentifierFieldNames(['id']); 105 | $metadata->mapField([ 106 | 'fieldName' => 'id', 107 | ]); 108 | $metadata->mapField(['fieldName' => 'username']); 109 | $metadata->mapField(['fieldName' => 'password']); 110 | } 111 | 112 | /** 113 | * @see HydratableInterface 114 | * 115 | * @param mixed[] $data 116 | */ 117 | public function hydrate(array $data, ObjectManagerInterface $objectManager): void 118 | { 119 | if (isset($data['id'])) { 120 | $this->id = $data['id']; 121 | } 122 | 123 | if (isset($data['username'])) { 124 | $this->username = $data['username']; 125 | } 126 | 127 | if (isset($data['password'])) { 128 | $this->password = $data['password']; 129 | } 130 | } 131 | 132 | /** 133 | * @see PersistableInterface 134 | * 135 | * @return mixed[] 136 | */ 137 | public function preparePersistChangeSet(): array 138 | { 139 | $changeSet = [ 140 | 'username' => $this->username, 141 | 'password' => $this->password, 142 | ]; 143 | 144 | if ($this->id !== null) { 145 | $changeSet['id'] = $this->id; 146 | } 147 | 148 | return $changeSet; 149 | } 150 | 151 | /** 152 | * @see PersistableInterface 153 | * 154 | * @return mixed[] 155 | */ 156 | public function prepareUpdateChangeSet(ChangeSet $changeSet): array 157 | { 158 | $changeSet = array_map(static function (Change $change) { 159 | return $change->getNewValue(); 160 | }, $changeSet->getChanges()); 161 | 162 | $changeSet['id'] = $this->id; 163 | 164 | return $changeSet; 165 | } 166 | 167 | /** 168 | * Assign identifier to object. 169 | * 170 | * @param mixed[] $identifier 171 | */ 172 | public function assignIdentifier(array $identifier): void 173 | { 174 | $this->id = $identifier['id']; 175 | } 176 | } 177 | 178 | Mapper Services 179 | --------------- 180 | 181 | Create all the necessary services for the mapper: 182 | 183 | .. code-block:: php 184 | 185 | use Doctrine\Common\Collections\ArrayCollection; 186 | use Doctrine\Common\EventManager; 187 | use Doctrine\SkeletonMapper\DataRepository\ArrayObjectDataRepository; 188 | use Doctrine\SkeletonMapper\Hydrator\BasicObjectHydrator; 189 | use Doctrine\SkeletonMapper\Mapping\ClassMetadata; 190 | use Doctrine\SkeletonMapper\Mapping\ClassMetadataFactory; 191 | use Doctrine\SkeletonMapper\Mapping\ClassMetadataInstantiator; 192 | use Doctrine\SkeletonMapper\ObjectFactory; 193 | use Doctrine\SkeletonMapper\ObjectIdentityMap; 194 | use Doctrine\SkeletonMapper\ObjectManager; 195 | use Doctrine\SkeletonMapper\ObjectRepository\BasicObjectRepository; 196 | use Doctrine\SkeletonMapper\ObjectRepository\ObjectRepositoryFactory; 197 | use Doctrine\SkeletonMapper\Persister\ArrayObjectPersister; 198 | use Doctrine\SkeletonMapper\Persister\ObjectPersisterFactory; 199 | 200 | $eventManager = new EventManager(); 201 | $classMetadataFactory = new ClassMetadataFactory(new ClassMetadataInstantiator()); 202 | $objectFactory = new ObjectFactory(); 203 | $objectRepositoryFactory = new ObjectRepositoryFactory(); 204 | $objectPersisterFactory = new ObjectPersisterFactory(); 205 | $objectIdentityMap = new ObjectIdentityMap($objectRepositoryFactory); 206 | 207 | $userClassMetadata = new ClassMetadata(User::class); 208 | $userClassMetadata->setIdentifier(['id']); 209 | $userClassMetadata->setIdentifierFieldNames(['id']); 210 | $userClassMetadata->mapField([ 211 | 'fieldName' => 'id', 212 | ]); 213 | $userClassMetadata->mapField([ 214 | 'fieldName' => 'username', 215 | ]); 216 | $userClassMetadata->mapField([ 217 | 'fieldName' => 'password', 218 | ]); 219 | 220 | $classMetadataFactory->setMetadataFor(User::class, $userClassMetadata); 221 | 222 | $objectManager = new ObjectManager( 223 | $objectRepositoryFactory, 224 | $objectPersisterFactory, 225 | $objectIdentityMap, 226 | $classMetadataFactory, 227 | $eventManager 228 | ); 229 | 230 | $users = new ArrayCollection([ 231 | 1 => [ 232 | 'id' => 1, 233 | 'username' => 'jwage', 234 | 'password' => 'password', 235 | ], 236 | 2 => [ 237 | 'id' => 2, 238 | 'username' => 'romanb', 239 | 'password' => 'password', 240 | ], 241 | ]); 242 | 243 | $userDataRepository = new ArrayObjectDataRepository( 244 | $objectManager, $users, User::class 245 | ); 246 | $userPersister = new ArrayObjectPersister( 247 | $objectManager, $users, User::class 248 | ); 249 | 250 | $userHydrator = new BasicObjectHydrator($objectManager); 251 | $userRepository = new BasicObjectRepository( 252 | $objectManager, 253 | $userDataRepository, 254 | $objectFactory, 255 | $userHydrator, 256 | $eventManager, 257 | User::class 258 | ); 259 | 260 | $objectRepositoryFactory->addObjectRepository(User::class, $userRepository); 261 | $objectPersisterFactory->addObjectPersister(User::class, $userPersister); 262 | 263 | Manage User Instances 264 | --------------------- 265 | 266 | Now you can manage ``User`` instances and they will be persisted to the 267 | ``ArrayCollection`` instance we created above: 268 | 269 | .. code-block:: php 270 | 271 | // create and persist a new user 272 | $user = new User(); 273 | $user->setId(3); 274 | $user->setUsername('ocramius'); 275 | $user->setPassword('test'); 276 | 277 | $objectManager->persist($user); 278 | $objectManager->flush(); 279 | $objectManager->clear(); 280 | 281 | print_r($users); 282 | 283 | $user = $objectManager->find(User::class, 3); 284 | 285 | // modify the user 286 | $user->setUsername('guilherme'); 287 | 288 | $objectManager->flush(); 289 | 290 | print_r($users); 291 | 292 | // remove the user 293 | $objectManager->remove($user); 294 | $objectManager->flush(); 295 | 296 | print_r($users); 297 | 298 | Of course if you want to be in complete control and implement custom 299 | code for all the above interfaces you can do so. You could write and 300 | read from a CSV file, an XML document or any data source you can 301 | imagine. 302 | 303 | Custom Implementation 304 | ===================== 305 | 306 | To implement your own custom reading and writing, you need to implement 307 | the ``ObjectDataRepositoryInterface`` and ``ObjectPersisterInterface`` interfaces 308 | and use those concrete implementations instead of the ``ArrayObjectDataRepository`` 309 | and ``ArrayObjectPersister`` that we did our test with before. 310 | 311 | Base Classes 312 | ------------ 313 | 314 | The Skeleton Mapper comes with some base classes that give you some boiler plate code 315 | so you can more quickly implement all the required interfaces. 316 | 317 | To implement your data reading, extend the ``BasicObjectDataRepository`` class: 318 | 319 | .. code-block:: php 320 | 321 | use Doctrine\SkeletonMapper\DataRepository\BasicObjectDataRepository; 322 | use Doctrine\SkeletonMapper\ObjectManagerInterface; 323 | 324 | class MyObjectDataRepository extends BasicObjectDataRepository 325 | { 326 | public function __construct( 327 | ObjectManagerInterface $objectManager, 328 | string $className 329 | ) { 330 | parent::__construct($objectManager, $className); 331 | 332 | // inject some other dependencies to the class 333 | } 334 | 335 | /** 336 | * @return mixed[][] 337 | */ 338 | public function findAll() : array 339 | { 340 | // get $objectsData 341 | 342 | return $objectsData; 343 | } 344 | 345 | /** 346 | * @param mixed[] $criteria 347 | * @param mixed[] $orderBy 348 | * 349 | * @return mixed[][] 350 | */ 351 | public function findBy( 352 | array $criteria, 353 | array|null $orderBy = null, 354 | int|null $limit = null, 355 | int|null $offset = null, 356 | ) : array { 357 | // get $objectsData 358 | 359 | return $objectsData; 360 | } 361 | 362 | /** 363 | * @param mixed[] $criteria 364 | * 365 | * @return null|mixed[] 366 | */ 367 | public function findOneBy(array $criteria): array|null 368 | { 369 | // get $objectData 370 | 371 | return $objectData; 372 | } 373 | } 374 | 375 | 376 | To implement your persistence, extend the ``BasicObjectPersister`` class: 377 | 378 | .. code-block:: php 379 | 380 | use Doctrine\SkeletonMapper\ObjectManagerInterface; 381 | use Doctrine\SkeletonMapper\Persister\BasicObjectPersister; 382 | use Doctrine\SkeletonMapper\UnitOfWork\ChangeSet; 383 | 384 | class MyObjectPersister extends BasicObjectPersister 385 | { 386 | public function __construct( 387 | ObjectManagerInterface $objectManager, 388 | string $className 389 | ) { 390 | parent::__construct($objectManager, $className); 391 | 392 | // inject some other dependencies to the class 393 | } 394 | 395 | /** 396 | * @return mixed[] 397 | */ 398 | public function persistObject(object $object): array 399 | { 400 | $data = $this->preparePersistChangeSet($object); 401 | 402 | $class = $this->getClassMetadata(); 403 | 404 | // write the $data 405 | 406 | return $data; 407 | } 408 | 409 | /** 410 | * @return mixed[] 411 | */ 412 | public function updateObject(object $object, ChangeSet $changeSet): array 413 | { 414 | $changeSet = $this->prepareUpdateChangeSet($object, $changeSet); 415 | 416 | $class = $this->getClassMetadata(); 417 | $identifier = $this->getObjectIdentifier($object); 418 | 419 | $objectData = []; 420 | 421 | foreach ($changeSet as $key => $value) { 422 | $objectData[$key] = $value; 423 | } 424 | 425 | // update the $objectData 426 | 427 | return $objectData; 428 | } 429 | 430 | public function removeObject(object $object): void 431 | { 432 | $class = $this->getClassMetadata(); 433 | $identifier = $this->getObjectIdentifier($object); 434 | 435 | // remove the object 436 | } 437 | } 438 | 439 | Now you can use them like this: 440 | 441 | .. code-block:: php 442 | 443 | $userDataRepository = new MyObjectDataRepository( 444 | $objectManager, User::class 445 | ); 446 | $userPersister = new MyObjectPersister( 447 | $objectManager, User::class 448 | ); 449 | 450 | $userHydrator = new BasicObjectHydrator($objectManager); 451 | $userRepository = new BasicObjectRepository( 452 | $objectManager, 453 | $userDataRepository, 454 | $objectFactory, 455 | $userHydrator, 456 | $eventManager, 457 | User::class 458 | ); 459 | 460 | $objectRepositoryFactory->addObjectRepository(User::class, $userRepository); 461 | $objectPersisterFactory->addObjectPersister(User::class, $userPersister); 462 | 463 | When you flush the ``ObjectManager``, the methods on the ``MyObjectDataRepository`` 464 | and ``MyObjectPersister`` will be called to handle writing the data. 465 | --------------------------------------------------------------------------------