├── .github └── workflows │ └── main.yml ├── phpstan.neon ├── LICENSE ├── composer.json └── src └── Codeception ├── Util └── ReflectionPropertyAccessor.php └── Module └── Doctrine2.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | php: [8.0, 8.1, 8.2, 8.3] 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | extensions: pdo, sqlite 22 | coverage: none 23 | 24 | - name: Validate composer.json and composer.lock 25 | run: composer validate 26 | 27 | - name: Install dependencies 28 | run: composer install --prefer-dist --no-progress --no-interaction 29 | 30 | - name: Run test suite 31 | run: php vendor/bin/codecept run 32 | 33 | - name: Run source code analysis 34 | run: php vendor/bin/phpstan 35 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | - tests 6 | excludePaths: 7 | analyse: 8 | - tests/data/doctrine2_fixtures/TestFixture1.php 9 | - tests/data/doctrine2_fixtures/TestFixture2.php 10 | - tests/_support/UnitTester.php 11 | checkMissingIterableValueType: false 12 | reportUnmatchedIgnoredErrors: true 13 | ignoreErrors: 14 | - path: tests/ 15 | message: '#Property \S+ is never read, only written#' 16 | - path: tests/ 17 | message: '#Property \S+ is unused#' 18 | - path: tests/ 19 | message: '#Method \S+ has parameter \S+ with no type specified#' 20 | - path: tests/ 21 | message: '#Method \S+ has no return type specified#' 22 | - path: tests/ 23 | message: '#(?:Method|Property) .+ with generic (?:interface|class) \S+ does not specify its types#' 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011 Michael Bodnarchuk and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "abandoned": "codeception/module-doctrine", 3 | "name": "codeception/module-doctrine2", 4 | "description": "Doctrine2 module for Codeception", 5 | "license": "MIT", 6 | "type": "library", 7 | "keywords": [ 8 | "codeception", 9 | "doctrine2" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Michael Bodnarchuk" 14 | }, 15 | { 16 | "name": "Alex Kunin" 17 | } 18 | ], 19 | "homepage": "https://codeception.com/", 20 | "require": { 21 | "php": "^8.0", 22 | "ext-json": "*", 23 | "ext-pdo": "*", 24 | "codeception/codeception": "^5.0.0-alpha2" 25 | }, 26 | "require-dev": { 27 | "codeception/stub": "^4.0", 28 | "doctrine/annotations": "^1.13", 29 | "doctrine/data-fixtures": "^1.5", 30 | "doctrine/orm": "^2.10", 31 | "phpstan/phpstan": "^1.0", 32 | "ramsey/uuid-doctrine": "^1.6", 33 | "symfony/cache": "^4.4 || ^5.4 || ^6.0" 34 | }, 35 | "conflict": { 36 | "codeception/codeception": "<5.0" 37 | }, 38 | "minimum-stability": "dev", 39 | "autoload": { 40 | "classmap": [ 41 | "src/" 42 | ] 43 | }, 44 | "config": { 45 | "classmap-authoritative": true, 46 | "sort-packages": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Codeception/Util/ReflectionPropertyAccessor.php: -------------------------------------------------------------------------------- 1 | hasProperty($field)) { 27 | $property = $reflectedEntity->getProperty($field); 28 | $property->setAccessible(true); 29 | return $property->getValue($obj); 30 | } 31 | $class = get_parent_class($class); 32 | } while ($class); 33 | throw new InvalidArgumentException('Property "' . $field . '" does not exists in class "' . get_class($obj) . '" and its parents'); 34 | } 35 | 36 | /** 37 | * @throws ReflectionException 38 | */ 39 | private function setPropertiesForClass(?object $obj, string $class, array $data): object 40 | { 41 | $reflectedEntity = new ReflectionClass($class); 42 | 43 | if ($obj === null) { 44 | $constructorParameters = []; 45 | $constructor = $reflectedEntity->getConstructor(); 46 | if (null !== $constructor) { 47 | foreach ($constructor->getParameters() as $parameter) { 48 | if ($parameter->isOptional()) { 49 | $constructorParameters[] = $parameter->getDefaultValue(); 50 | } elseif (array_key_exists($parameter->getName(), $data)) { 51 | $constructorParameters[] = $data[$parameter->getName()]; 52 | } else { 53 | throw new InvalidArgumentException( 54 | 'Constructor parameter "' . $parameter->getName() . '" missing' 55 | ); 56 | } 57 | } 58 | } 59 | 60 | $obj = $reflectedEntity->newInstance(...$constructorParameters); 61 | } 62 | 63 | foreach ($reflectedEntity->getProperties() as $property) { 64 | if (isset($data[$property->name])) { 65 | $property->setAccessible(true); 66 | $property->setValue($obj, $data[$property->name]); 67 | } 68 | } 69 | return $obj; 70 | } 71 | 72 | /** 73 | * @throws ReflectionException 74 | */ 75 | public function setProperties(?object $obj, array $data): void 76 | { 77 | if (!$obj || !is_object($obj)) { 78 | throw new InvalidArgumentException('Cannot set properties for "' . gettype($obj) . '", expecting object'); 79 | } 80 | $class = get_class($obj); 81 | do { 82 | $obj = $this->setPropertiesForClass($obj, $class, $data); 83 | $class = get_parent_class($class); 84 | } while ($class); 85 | } 86 | 87 | /** 88 | * @throws ReflectionException 89 | */ 90 | public function createWithProperties(string $class, array $data): object 91 | { 92 | $obj = null; 93 | do { 94 | $obj = $this->setPropertiesForClass($obj, $class, $data); 95 | $class = get_parent_class($class); 96 | } while ($class); 97 | return $obj; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Codeception/Module/Doctrine2.php: -------------------------------------------------------------------------------- 1 | fooRepository = $I->grabService(FooRepository::class); 100 | * } 101 | * ``` 102 | * 103 | * Now you have access to all your familiar repository methods in your tests, e.g.: 104 | * 105 | * ```php 106 | * $greenFoo = $this->fooRepository->findOneBy(['color' => 'green']); 107 | * ``` 108 | * 109 | * ## Public Properties 110 | * 111 | * * `em` - Entity Manager 112 | * 113 | * ## Doctrine `Criteria` as query parameters 114 | * 115 | * Every method that expects some query parameters (`see...()`, 116 | * `dontSee...()`, `grab...()`) also accepts an instance of 117 | * [\Doctrine\Common\Collections\Criteria](https://www.doctrine-project.org/projects/doctrine-collections/en/stable/expressions.html) 118 | * for more flexibility, e.g.: 119 | * 120 | * ```php 121 | * $I->seeInRepository(User::class, [ 122 | * 'name' => 'John', 123 | * Criteria::create()->where( 124 | * Criteria::expr()->endsWith('email', '@domain.com') 125 | * ), 126 | * ]); 127 | * ``` 128 | * 129 | * If criteria is just a `->where(...)` construct, you can pass just expression without criteria wrapper: 130 | * 131 | * ```php 132 | * $I->seeInRepository(User::class, [ 133 | * 'name' => 'John', 134 | * Criteria::expr()->endsWith('email', '@domain.com'), 135 | * ]); 136 | * ``` 137 | * 138 | * Criteria can be used not only to filter data, but also to change the order of results: 139 | * 140 | * ```php 141 | * $I->grabEntitiesFromRepository('User', [ 142 | * 'status' => 'active', 143 | * Criteria::create()->orderBy(['name' => 'asc']), 144 | * ]); 145 | * ``` 146 | * 147 | * Note that key is ignored, because actual field name is part of criteria and/or expression. 148 | */ 149 | class Doctrine2 extends CodeceptionModule implements DependsOnModule, DataMapper 150 | { 151 | private ?DoctrineProvider $dependentModule = null; 152 | 153 | /** 154 | * @var array 155 | */ 156 | protected array $config = [ 157 | 'cleanup' => true, 158 | 'connection_callback' => false, 159 | 'depends' => null, 160 | 'purge_mode' => 1, // ORMPurger::PURGE_MODE_DELETE 161 | ]; 162 | 163 | protected string $dependencyMessage = <<config['connection_callback']) { 184 | return []; 185 | } 186 | 187 | return [DoctrineProvider::class => $this->dependencyMessage]; 188 | } 189 | 190 | public function _inject(DoctrineProvider $dependentModule = null): void 191 | { 192 | $this->dependentModule = $dependentModule; 193 | } 194 | 195 | /** 196 | * @param array $settings 197 | * 198 | * @throws ModuleConfigException 199 | */ 200 | public function _beforeSuite($settings = []): void 201 | { 202 | $this->retrieveEntityManager(); 203 | } 204 | 205 | public function _before(TestInterface $test): void 206 | { 207 | $this->cleanupEntityManager(); 208 | } 209 | 210 | public function onReconfigure(): void 211 | { 212 | if (!$this->em instanceof EntityManagerInterface) { 213 | return; 214 | } 215 | 216 | if ($this->config['cleanup'] && $this->em->getConnection()->isTransactionActive()) { 217 | try { 218 | $this->em->getConnection()->rollback(); 219 | $this->debugSection('Database', 'Transaction cancelled; all changes reverted.'); 220 | } catch (PDOException $e) { 221 | } 222 | } 223 | 224 | $this->clean(); 225 | $this->em->getConnection()->close(); 226 | 227 | $this->cleanupEntityManager(); 228 | } 229 | 230 | protected function retrieveEntityManager(): void 231 | { 232 | if ($this->dependentModule) { 233 | $this->em = $this->dependentModule->_getEntityManager(); 234 | } else { 235 | if (is_callable($this->config['connection_callback'])) { 236 | $this->em = call_user_func($this->config['connection_callback']); 237 | } 238 | } 239 | 240 | if (!$this->em) { 241 | throw new ModuleConfigException( 242 | __CLASS__, 243 | "EntityManager can't be obtained.\n \n" 244 | . "Please specify either `connection_callback` config option\n" 245 | . "with callable which will return instance of EntityManager or\n" 246 | . "pass a dependent module which are Symfony or ZF2\n" 247 | . "to connect to Doctrine using Dependency Injection Container" 248 | ); 249 | } 250 | 251 | 252 | if (!($this->em instanceof EntityManagerInterface)) { 253 | throw new ModuleConfigException( 254 | __CLASS__, 255 | "Connection object is not an instance of \\Doctrine\\ORM\\EntityManagerInterface.\n" 256 | . "Use `connection_callback` or dependent framework modules to specify one" 257 | ); 258 | } 259 | 260 | $this->em->getConnection()->connect(); 261 | } 262 | 263 | /** 264 | * @return void 265 | */ 266 | public function _after(TestInterface $test) 267 | { 268 | if (!$this->em instanceof EntityManagerInterface) { 269 | return; 270 | } 271 | if ($this->config['cleanup'] && $this->em->getConnection()->isTransactionActive()) { 272 | try { 273 | while ($this->em->getConnection()->getTransactionNestingLevel() > 0) { 274 | $this->em->getConnection()->rollback(); 275 | } 276 | $this->debugSection('Database', 'Transaction cancelled; all changes reverted.'); 277 | } catch (PDOException $e) { 278 | } 279 | } 280 | $this->clean(); 281 | $this->em->getConnection()->close(); 282 | } 283 | 284 | protected function clean(): void 285 | { 286 | $em = $this->em; 287 | 288 | $reflectedEm = new ReflectionClass($em); 289 | if ($reflectedEm->hasProperty('repositories')) { 290 | $property = $reflectedEm->getProperty('repositories'); 291 | $property->setAccessible(true); 292 | $property->setValue($em, []); 293 | } 294 | $this->em->clear(); 295 | } 296 | 297 | /** 298 | * Performs $em->flush(); 299 | */ 300 | public function flushToDatabase(): void 301 | { 302 | $this->em->flush(); 303 | } 304 | 305 | /** 306 | * Performs $em->refresh() on every passed entity: 307 | * 308 | * ``` php 309 | * $I->refreshEntities($user); 310 | * $I->refreshEntities([$post1, $post2, $post3]]); 311 | * ``` 312 | * 313 | * This can useful in acceptance tests where entity can become invalid due to 314 | * external (relative to entity manager used in tests) changes. 315 | * 316 | * @param object|object[] $entities 317 | */ 318 | public function refreshEntities($entities): void 319 | { 320 | if (!is_array($entities)) { 321 | $entities = [$entities]; 322 | } 323 | 324 | foreach ($entities as $entity) { 325 | $this->em->refresh($entity); 326 | } 327 | } 328 | 329 | /** 330 | * Performs $em->clear(): 331 | * 332 | * ``` php 333 | * $I->clearEntityManager(); 334 | * ``` 335 | */ 336 | public function clearEntityManager(): void 337 | { 338 | $this->em->clear(); 339 | } 340 | 341 | /** 342 | * Mocks the repository. 343 | * 344 | * With this action you can redefine any method of any repository. 345 | * Please, note: this fake repositories will be accessible through entity manager till the end of test. 346 | * 347 | * Example: 348 | * 349 | * ``` php 350 | * haveFakeRepository(User::class, ['findByUsername' => function($username) { return null; }]); 353 | * 354 | * ``` 355 | * 356 | * This creates a stub class for Entity\User repository with redefined method findByUsername, 357 | * which will always return the NULL value. 358 | * 359 | * @param class-string $className 360 | * @param array $methods 361 | */ 362 | public function haveFakeRepository(string $className, array $methods = []): void 363 | { 364 | $em = $this->em; 365 | 366 | $metadata = $em->getMetadataFactory()->getMetadataFor($className); 367 | $customRepositoryClassName = $metadata->customRepositoryClassName; 368 | 369 | if (!$customRepositoryClassName) { 370 | $customRepositoryClassName = '\Doctrine\ORM\EntityRepository'; 371 | } 372 | 373 | $mock = Stub::make( 374 | $customRepositoryClassName, 375 | array_merge( 376 | [ 377 | '_entityName' => $metadata->name, 378 | '_em' => $em, 379 | '_class' => $metadata 380 | ], 381 | $methods 382 | ) 383 | ); 384 | 385 | $em->clear(); 386 | $reflectedEm = new ReflectionClass($em); 387 | 388 | 389 | if ($reflectedEm->hasProperty('repositories')) { 390 | //Support doctrine versions before 2.4.0 391 | 392 | $property = $reflectedEm->getProperty('repositories'); 393 | $property->setAccessible(true); 394 | $property->setValue($em, array_merge($property->getValue($em), [$className => $mock])); 395 | } elseif ($reflectedEm->hasProperty('repositoryFactory')) { 396 | //For doctrine 2.4.0+ versions 397 | 398 | $repositoryFactoryProperty = $reflectedEm->getProperty('repositoryFactory'); 399 | $repositoryFactoryProperty->setAccessible(true); 400 | $repositoryFactory = $repositoryFactoryProperty->getValue($em); 401 | 402 | $reflectedRepositoryFactory = new ReflectionClass($repositoryFactory); 403 | 404 | if ($reflectedRepositoryFactory->hasProperty('repositoryList')) { 405 | $repositoryListProperty = $reflectedRepositoryFactory->getProperty('repositoryList'); 406 | $repositoryListProperty->setAccessible(true); 407 | 408 | $repositoryHash = $em->getClassMetadata($className)->getName() . spl_object_id($em); 409 | $repositoryListProperty->setValue( 410 | $repositoryFactory, 411 | [$repositoryHash => $mock] 412 | ); 413 | 414 | $repositoryFactoryProperty->setValue($em, $repositoryFactory); 415 | } else { 416 | $this->debugSection( 417 | 'Warning', 418 | 'Repository can\'t be mocked, the EventManager\'s repositoryFactory doesn\'t have "repositoryList" property' 419 | ); 420 | } 421 | } else { 422 | $this->debugSection( 423 | 'Warning', 424 | 'Repository can\'t be mocked, the EventManager class doesn\'t have "repositoryFactory" or "repositories" property' 425 | ); 426 | } 427 | } 428 | 429 | /** 430 | * Persists a record into the repository. 431 | * This method creates an entity, and sets its properties directly (via reflection). 432 | * Setters of the entity won't be executed, but you can create almost any entity and save it to the database. 433 | * If the entity has a constructor, for optional parameters the default value will be used and for non-optional parameters the given fields (with a matching name) will be passed when calling the constructor before the properties get set directly (via reflection). 434 | * 435 | * Returns the primary key of the newly created entity. The primary key value is extracted using Reflection API. 436 | * If the primary key is composite, an array of values is returned. 437 | * 438 | * ```php 439 | * $I->haveInRepository(User::class, ['name' => 'davert']); 440 | * ``` 441 | * 442 | * This method also accepts instances as first argument, which is useful when the entity constructor 443 | * has some arguments: 444 | * 445 | * ```php 446 | * $I->haveInRepository(new User($arg), ['name' => 'davert']); 447 | * ``` 448 | * 449 | * Alternatively, constructor arguments can be passed by name. Given User constructor signature is `__constructor($arg)`, the example above could be rewritten like this: 450 | * 451 | * ```php 452 | * $I->haveInRepository(User::class, ['arg' => $arg, 'name' => 'davert']); 453 | * ``` 454 | * 455 | * If the entity has relations, they can be populated too. In case of 456 | * [OneToMany](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/association-mapping.html#one-to-many-bidirectional) 457 | * the following format is expected: 458 | * 459 | * ```php 460 | * $I->haveInRepository(User::class, [ 461 | * 'name' => 'davert', 462 | * 'posts' => [ 463 | * ['title' => 'Post 1'], 464 | * ['title' => 'Post 2'], 465 | * ], 466 | * ]); 467 | * ``` 468 | * 469 | * For [ManyToOne](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/association-mapping.html#many-to-one-unidirectional) 470 | * the format is slightly different: 471 | * 472 | * ```php 473 | * $I->haveInRepository(User::class, [ 474 | * 'name' => 'davert', 475 | * 'post' => [ 476 | * 'title' => 'Post 1', 477 | * ], 478 | * ]); 479 | * ``` 480 | * 481 | * This works recursively, so you can create deep structures in a single call. 482 | * 483 | * Note that `$em->persist()`, `$em->refresh()`, and `$em->flush()` are called every time. 484 | * 485 | * @template T of object 486 | * @param class-string|T $classNameOrInstance 487 | * @param array $data 488 | * @return mixed 489 | */ 490 | public function haveInRepository($classNameOrInstance, array $data = []) 491 | { 492 | // Here we'll have array of all instances (including any relations) created: 493 | $instances = []; 494 | 495 | // Create and/or populate main instance and gather all created relations: 496 | if (is_object($classNameOrInstance)) { 497 | $instance = $this->populateEntity($classNameOrInstance, $data, $instances); 498 | } elseif (is_string($classNameOrInstance)) { 499 | $instance = $this->instantiateAndPopulateEntity($classNameOrInstance, $data, $instances); 500 | } else { 501 | throw new InvalidArgumentException(sprintf('Doctrine2::haveInRepository expects a class name or instance as first argument, got "%s" instead', gettype($classNameOrInstance))); 502 | } 503 | 504 | // Flush all changes to database and then refresh all entities. We need this because 505 | // currently all assignments are done via Reflection API without using setters, which means 506 | // all OneToMany relations won't get set properly as real setter method would use some 507 | // Collection operation. 508 | $this->em->flush(); 509 | $this->refreshEntities($instances); 510 | 511 | $pk = $this->extractPrimaryKey($instance); 512 | 513 | $this->debugEntityCreation($instance, $pk); 514 | 515 | return $pk; 516 | } 517 | 518 | /** 519 | * @template T of object 520 | * @param class-string $className 521 | * @param array $data 522 | * @param list $instances 523 | * @return T 524 | * @throws ReflectionException 525 | */ 526 | private function instantiateAndPopulateEntity(string $className, array $data, array &$instances) 527 | { 528 | $rpa = new ReflectionPropertyAccessor(); 529 | [$scalars, $relations] = $this->splitScalarsAndRelations($className, $data); 530 | // Pass relations that are already objects to the constructor, too 531 | $properties = array_merge( 532 | $scalars, 533 | array_filter($relations, fn($relation) => is_object($relation)) 534 | ); 535 | /** @var T $instance */ 536 | $instance = $rpa->createWithProperties($className, $properties); 537 | $this->populateEntity($instance, $data, $instances); 538 | return $instance; 539 | } 540 | 541 | /** 542 | * @template T of object 543 | * @param T $instance 544 | * @param array $data 545 | * @param list $instances 546 | * @return T 547 | * @throws ReflectionException 548 | */ 549 | private function populateEntity($instance, array $data, array &$instances) 550 | { 551 | $rpa = new ReflectionPropertyAccessor(); 552 | $className = get_class($instance); 553 | $instances[] = $instance; 554 | [$scalars, $relations] = $this->splitScalarsAndRelations($className, $data); 555 | $rpa->setProperties( 556 | $instance, 557 | array_merge( 558 | $scalars, 559 | $this->instantiateRelations($className, $instance, $relations, $instances) 560 | ) 561 | ); 562 | $this->populateEmbeddables($instance, $data); 563 | $this->em->persist($instance); 564 | return $instance; 565 | } 566 | 567 | /** 568 | * @param class-string $className 569 | * @param array $data 570 | * @return array{0: array, 1: array} 571 | */ 572 | private function splitScalarsAndRelations(string $className, array $data): array 573 | { 574 | $scalars = []; 575 | $relations = []; 576 | 577 | $metadata = $this->em->getClassMetadata($className); 578 | 579 | foreach ($data as $field => $value) { 580 | if ($metadata->hasAssociation($field)) { 581 | $relations[$field] = $value; 582 | } else { 583 | $scalars[$field] = $value; 584 | } 585 | } 586 | 587 | return [$scalars, $relations]; 588 | } 589 | 590 | /** 591 | * @param class-string $className 592 | * @param object $master 593 | * @param array $data 594 | * @param list $instances 595 | * @return array 596 | */ 597 | private function instantiateRelations(string $className, $master, array $data, array &$instances): array 598 | { 599 | $metadata = $this->em->getClassMetadata($className); 600 | 601 | foreach ($data as $field => $value) { 602 | if (is_array($value) && $metadata->hasAssociation($field)) { 603 | unset($data[$field]); 604 | if ($metadata->isCollectionValuedAssociation($field)) { 605 | foreach ($value as $subvalue) { 606 | if (!is_array($subvalue)) { 607 | throw new InvalidArgumentException('Association "' . $field . '" of entity "' . $className . '" requires array as input, got "' . gettype($subvalue) . '" instead"'); 608 | } 609 | $instance = $this->instantiateAndPopulateEntity( 610 | $metadata->getAssociationTargetClass($field), 611 | array_merge($subvalue, [ 612 | $metadata->getAssociationMappedByTargetField($field) => $master, 613 | ]), 614 | $instances 615 | ); 616 | $instances[] = $instance; 617 | } 618 | } else { 619 | $instance = $this->instantiateAndPopulateEntity( 620 | $metadata->getAssociationTargetClass($field), 621 | $value, 622 | $instances 623 | ); 624 | $instances[] = $instance; 625 | $data[$field] = $instance; 626 | } 627 | } 628 | } 629 | 630 | return $data; 631 | } 632 | 633 | /** 634 | * @param object $instance 635 | * @return array|mixed 636 | * @throws ReflectionException 637 | */ 638 | private function extractPrimaryKey(object $instance) 639 | { 640 | $className = get_class($instance); 641 | $metadata = $this->em->getClassMetadata($className); 642 | $rpa = new ReflectionPropertyAccessor(); 643 | if ($metadata->isIdentifierComposite) { 644 | $pk = []; 645 | foreach ($metadata->identifier as $field) { 646 | $pk[] = $rpa->getProperty($instance, $field); 647 | } 648 | } else { 649 | $pk = $rpa->getProperty($instance, $metadata->identifier[0]); 650 | } 651 | 652 | return $pk; 653 | } 654 | 655 | /** 656 | * Loads fixtures. Fixture can be specified as a fully qualified class name, 657 | * an instance, or an array of class names/instances. 658 | * 659 | * ```php 660 | * loadFixtures(AppFixtures::class); 662 | * $I->loadFixtures([AppFixtures1::class, AppFixtures2::class]); 663 | * $I->loadFixtures(new AppFixtures); 664 | * ``` 665 | * 666 | * By default fixtures are loaded in 'append' mode. To replace all 667 | * data in database, use `false` as second parameter: 668 | * 669 | * ```php 670 | * loadFixtures(AppFixtures::class, false); 672 | * ``` 673 | * 674 | * This method requires [`doctrine/data-fixtures`](https://github.com/doctrine/data-fixtures) to be installed. 675 | * 676 | * @param class-string|class-string[]|list $fixtures 677 | * @param bool $append 678 | * @throws ModuleException 679 | * @throws ModuleRequireException 680 | */ 681 | public function loadFixtures($fixtures, bool $append = true): void 682 | { 683 | if (!class_exists(Loader::class) 684 | || !class_exists(ORMPurger::class) 685 | || !class_exists(ORMExecutor::class)) { 686 | throw new ModuleRequireException( 687 | __CLASS__, 688 | 'Doctrine fixtures support in unavailable.\nPlease, install doctrine/data-fixtures.' 689 | ); 690 | } 691 | 692 | if (!is_array($fixtures)) { 693 | $fixtures = [$fixtures]; 694 | } 695 | 696 | $loader = new Loader(); 697 | 698 | foreach ($fixtures as $fixture) { 699 | if (is_string($fixture)) { 700 | if (!class_exists($fixture)) { 701 | throw new ModuleException( 702 | __CLASS__, 703 | sprintf( 704 | 'Fixture class "%s" does not exist', 705 | $fixture 706 | ) 707 | ); 708 | } 709 | 710 | if (!is_a($fixture, FixtureInterface::class, true)) { 711 | throw new ModuleException( 712 | __CLASS__, 713 | sprintf( 714 | 'Fixture class "%s" does not inherit from "%s"', 715 | $fixture, 716 | FixtureInterface::class 717 | ) 718 | ); 719 | } 720 | 721 | try { 722 | $fixtureInstance = new $fixture(); 723 | } catch (Exception $exception) { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/6574 724 | throw new ModuleException( 725 | __CLASS__, 726 | sprintf( 727 | 'Fixture class "%s" could not be loaded, got %s%s', 728 | $fixture, 729 | get_class($exception), 730 | empty($exception->getMessage()) ? '' : ': ' . $exception->getMessage() 731 | ) 732 | ); 733 | } 734 | } elseif (is_object($fixture)) { 735 | if (!$fixture instanceof FixtureInterface) { 736 | throw new ModuleException( 737 | __CLASS__, 738 | sprintf( 739 | 'Fixture "%s" does not inherit from "%s"', 740 | get_class($fixture), 741 | FixtureInterface::class 742 | ) 743 | ); 744 | } 745 | 746 | $fixtureInstance = $fixture; 747 | } else { 748 | throw new ModuleException( 749 | __CLASS__, 750 | sprintf( 751 | 'Fixture is expected to be an instance or class name, inherited from "%s"; got "%s" instead', 752 | FixtureInterface::class, 753 | gettype($fixture) 754 | ) 755 | ); 756 | } 757 | 758 | try { 759 | $loader->addFixture($fixtureInstance); 760 | } catch (Exception $exception) { 761 | throw new ModuleException( 762 | __CLASS__, 763 | sprintf( 764 | 'Fixture class "%s" could not be loaded, got %s%s', 765 | get_class($fixtureInstance), 766 | get_class($exception), 767 | empty($exception->getMessage()) ? '' : ': ' . $exception->getMessage() 768 | ) 769 | ); 770 | } 771 | } 772 | 773 | try { 774 | $purger = new ORMPurger($this->em); 775 | $purger->setPurgeMode($this->config['purge_mode']); 776 | $executor = new ORMExecutor($this->em, $purger); 777 | $executor->execute($loader->getFixtures(), $append); 778 | } catch (Exception $exception) { 779 | throw new ModuleException( 780 | __CLASS__, 781 | sprintf( 782 | 'Fixtures could not be loaded, got %s%s', 783 | get_class($exception), 784 | empty($exception->getMessage()) ? '' : ': ' . $exception->getMessage() 785 | ) 786 | ); 787 | } 788 | } 789 | 790 | /** 791 | * Entity can have embeddable as a field, in which case $data argument of persistEntity() and haveInRepository() 792 | * could contain keys like {field}.{subField}, where {field} is name of entity's embeddable field, and {subField} 793 | * is embeddable's field. 794 | * 795 | * This method checks if entity has embeddables, and if data have keys as described above, and then uses 796 | * Reflection API to set values. 797 | * 798 | * See https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/embeddables.html for 799 | * details about this Doctrine feature. 800 | * 801 | * @param object $entityObject 802 | * @param array $data 803 | * 804 | * @return void 805 | * @throws ReflectionException 806 | */ 807 | private function populateEmbeddables(object $entityObject, array $data): void 808 | { 809 | $rpa = new ReflectionPropertyAccessor(); 810 | $metadata = $this->em->getClassMetadata(get_class($entityObject)); 811 | foreach (array_keys($metadata->embeddedClasses) as $embeddedField) { 812 | $embeddedData = []; 813 | foreach ($data as $entityField => $value) { 814 | $parts = explode('.', $entityField, 2); 815 | if (count($parts) === 2 && $parts[0] === $embeddedField) { 816 | $embeddedData[$parts[1]] = $value; 817 | } 818 | } 819 | 820 | if ($embeddedData !== []) { 821 | $rpa->setProperties($rpa->getProperty($entityObject, $embeddedField), $embeddedData); 822 | } 823 | } 824 | } 825 | 826 | /** 827 | * Flushes changes to database, and executes a query with parameters defined in an array. 828 | * You can use entity associations to build complex queries. 829 | * 830 | * Example: 831 | * 832 | * ``` php 833 | * seeInRepository(User::class, ['name' => 'davert']); 835 | * $I->seeInRepository(User::class, ['name' => 'davert', 'Company' => ['name' => 'Codegyre']]); 836 | * $I->seeInRepository(Client::class, ['User' => ['Company' => ['name' => 'Codegyre']]]); 837 | * ``` 838 | * 839 | * Fails if record for given criteria can\'t be found, 840 | * 841 | * @param class-string $entity 842 | * @param array $params 843 | * @return void 844 | */ 845 | public function seeInRepository(string $entity, array $params = []): void 846 | { 847 | $res = $this->proceedSeeInRepository($entity, $params); 848 | $this->assert($res); 849 | } 850 | 851 | /** 852 | * Flushes changes to database and performs `findOneBy()` call for current repository. 853 | * 854 | * @param class-string $entity 855 | * @param array $params 856 | * @return void 857 | */ 858 | public function dontSeeInRepository(string $entity, array $params = []): void 859 | { 860 | $res = $this->proceedSeeInRepository($entity, $params); 861 | $this->assertNot($res); 862 | } 863 | 864 | /** 865 | * @param class-string $entity 866 | * @param array $params 867 | * 868 | * @return array{0: 'True', 1: bool, 2: non-empty-string} 869 | */ 870 | protected function proceedSeeInRepository(string $entity, array $params = []): array 871 | { 872 | // we need to store to database... 873 | $this->em->flush(); 874 | $qb = $this->em->getRepository($entity)->createQueryBuilder('s'); 875 | $this->buildAssociationQuery($qb, $entity, 's', $params); 876 | $this->debug($qb->getDQL()); 877 | $res = $qb->getQuery()->getArrayResult(); 878 | 879 | return ['True', (count($res) > 0), "$entity with " . $qb->getDQL()]; 880 | } 881 | 882 | /** 883 | * Selects field value from repository. 884 | * It builds a query based on an array of parameters. 885 | * You can use entity associations to build complex queries. 886 | * For Symfony users, it's recommended to [use the entity's repository instead](#Grabbing-Entities-with-Symfony) 887 | * 888 | * Example: 889 | * 890 | * ``` php 891 | * grabFromRepository(User::class, 'email', ['name' => 'davert']); 893 | * ``` 894 | * 895 | * @param class-string $entity 896 | * @param string $field 897 | * @param array $params 898 | * @return mixed 899 | */ 900 | public function grabFromRepository(string $entity, string $field, array $params = []) 901 | { 902 | // we need to store to database... 903 | $this->em->flush(); 904 | $qb = $this->em->getRepository($entity)->createQueryBuilder('s'); 905 | $qb->select('s.' . $field); 906 | $this->buildAssociationQuery($qb, $entity, 's', $params); 907 | $this->debug($qb->getDQL()); 908 | return $qb->getQuery()->getSingleScalarResult(); 909 | } 910 | 911 | /** 912 | * Selects entities from repository. 913 | * It builds a query based on an array of parameters. 914 | * You can use entity associations to build complex queries. 915 | * For Symfony users, it's recommended to [use the entity's repository instead](#Grabbing-Entities-with-Symfony) 916 | * 917 | * Example: 918 | * 919 | * ``` php 920 | * grabEntitiesFromRepository(User::class, ['name' => 'davert']); 922 | * ``` 923 | * 924 | * @template T of object 925 | * @param class-string $entity 926 | * @param array $params . For `IS NULL`, use `['field' => null]` 927 | * @return list 928 | */ 929 | public function grabEntitiesFromRepository(string $entity, array $params = []): array 930 | { 931 | // we need to store to database... 932 | $this->em->flush(); 933 | $qb = $this->em->getRepository($entity)->createQueryBuilder('s'); 934 | $qb->select('s'); 935 | $this->buildAssociationQuery($qb, $entity, 's', $params); 936 | $this->debug($qb->getDQL()); 937 | 938 | return $qb->getQuery()->getResult(); 939 | } 940 | 941 | /** 942 | * Selects a single entity from repository. 943 | * It builds a query based on an array of parameters. 944 | * You can use entity associations to build complex queries. 945 | * For Symfony users, it's recommended to [use the entity's repository instead](#Grabbing-Entities-with-Symfony) 946 | * 947 | * Example: 948 | * 949 | * ``` php 950 | * grabEntityFromRepository(User::class, ['id' => '1234']); 952 | * ``` 953 | * 954 | * @template T of object 955 | * @param class-string $entity 956 | * @param array $params . For `IS NULL`, use `['field' => null]` 957 | * @return T 958 | * @version 1.1 959 | */ 960 | public function grabEntityFromRepository(string $entity, array $params = []) 961 | { 962 | // we need to store to database... 963 | $this->em->flush(); 964 | $qb = $this->em->getRepository($entity)->createQueryBuilder('s'); 965 | $qb->select('s'); 966 | $this->buildAssociationQuery($qb, $entity, 's', $params); 967 | $this->debug($qb->getDQL()); 968 | 969 | return $qb->getQuery()->getSingleResult(); 970 | } 971 | 972 | protected function buildAssociationQuery(QueryBuilder $qb, string $assoc, string $alias, array $params): void 973 | { 974 | $paramIndex = 0; 975 | $this->_buildAssociationQuery($qb, $assoc, $alias, $params, $paramIndex); 976 | } 977 | 978 | protected function _buildAssociationQuery(QueryBuilder $qb, string $assoc, string $alias, array $params, int &$paramIndex): void 979 | { 980 | $data = $this->em->getClassMetadata($assoc); 981 | foreach ($params as $key => $val) { 982 | if ($data->associationMappings !== null && array_key_exists($key, $data->associationMappings)) { 983 | $map = $data->associationMappings[$key]; 984 | if (is_array($val)) { 985 | $qb->innerJoin("$alias.$key", "{$alias}__$key"); 986 | $this->_buildAssociationQuery($qb, $map['targetEntity'], "{$alias}__$key", $val, $paramIndex); 987 | continue; 988 | } 989 | } 990 | 991 | if ($val === null) { 992 | $qb->andWhere("$alias.$key IS NULL"); 993 | } elseif ($val instanceof Criteria) { 994 | $qb->addCriteria($val); 995 | } elseif ($val instanceof Expression) { 996 | $qb->addCriteria(Criteria::create()->where($val)); 997 | } else { 998 | $qb->andWhere(sprintf('%s.%s = ?%s', $alias, $key, $paramIndex)); 999 | $qb->setParameter($paramIndex, $val); 1000 | ++$paramIndex; 1001 | } 1002 | } 1003 | } 1004 | 1005 | public function _getEntityManager(): EntityManagerInterface 1006 | { 1007 | if (is_null($this->em)) { 1008 | $this->retrieveEntityManager(); 1009 | } 1010 | return $this->em; 1011 | } 1012 | 1013 | /** 1014 | * @param mixed $pks 1015 | * @throws ReflectionException 1016 | */ 1017 | private function debugEntityCreation(object $instance, $pks): void 1018 | { 1019 | $message = get_class($instance) . ' entity created with '; 1020 | 1021 | if (!is_array($pks)) { 1022 | $pks = [$pks]; 1023 | $message .= 'primary key '; 1024 | } else { 1025 | $message .= 'composite primary key of '; 1026 | } 1027 | 1028 | foreach ($pks as $pk) { 1029 | if ($this->isDoctrineEntity($pk)) { 1030 | $message .= get_class($pk) . ': ' . var_export($this->extractPrimaryKey($pk), true) . ', '; 1031 | } else { 1032 | $message .= var_export($pk, true) . ', '; 1033 | } 1034 | } 1035 | 1036 | $this->debug(trim($message, ' ,')); 1037 | } 1038 | 1039 | private function isDoctrineEntity(mixed $pk): bool 1040 | { 1041 | $isEntity = is_object($pk); 1042 | 1043 | if ($isEntity) { 1044 | try { 1045 | $this->em->getClassMetadata(get_class($pk)); 1046 | } catch (\Doctrine\ORM\Mapping\MappingException|\Doctrine\Persistence\Mapping\MappingException $exception) { 1047 | $isEntity = false; 1048 | } catch (\Doctrine\Common\Persistence\Mapping\MappingException $exception) { // @phpstan-ignore-line 1049 | $isEntity = false; 1050 | } 1051 | } 1052 | 1053 | return $isEntity; 1054 | } 1055 | 1056 | private function cleanupEntityManager(): void 1057 | { 1058 | $this->retrieveEntityManager(); 1059 | if ($this->config['cleanup']) { 1060 | if ($this->em->getConnection()->isTransactionActive()) { 1061 | try { 1062 | while ($this->em->getConnection()->getTransactionNestingLevel() > 0) { 1063 | $this->em->getConnection()->rollback(); 1064 | } 1065 | $this->debugSection('Database', 'Transaction cancelled; all changes reverted.'); 1066 | } catch (PDOException $e) { 1067 | } 1068 | } 1069 | 1070 | $this->em->getConnection()->beginTransaction(); 1071 | $this->debugSection('Database', 'Transaction started'); 1072 | } 1073 | } 1074 | } 1075 | --------------------------------------------------------------------------------