├── .github └── workflows │ └── ci.yml ├── .gitignore ├── bin └── orm-build ├── composer.json ├── phpunit.xml ├── psalm-baseline.xml ├── psalm.xml ├── src ├── ClassConfiguration.php ├── ClassMetadata.php ├── ClassMetadataBuilder.php ├── Configuration.php ├── EmbeddableConfiguration.php ├── EmbeddableMetadata.php ├── EntityConfiguration.php ├── EntityMetadata.php ├── Exception │ ├── EntityNotFoundException.php │ ├── IdentityConflictException.php │ ├── NoIdentityException.php │ ├── NonUniqueResultException.php │ ├── ORMException.php │ ├── UnknownEntityClassException.php │ └── UnknownPropertyException.php ├── Gateway.php ├── IdentityMap.php ├── ObjectFactory.php ├── Options.php ├── PropertyMapping.php ├── PropertyMapping │ ├── BoolMapping.php │ ├── BuiltinTypeMapping.php │ ├── EmbeddableMapping.php │ ├── EntityMapping.php │ ├── IntMapping.php │ ├── JsonMapping.php │ └── StringMapping.php ├── Proxy.php ├── ProxyBuilder.php ├── ProxyTemplate.php ├── Query.php ├── QueryOrderBy.php ├── QueryPredicate.php ├── RepositoryBuilder.php ├── RepositoryTemplate.php ├── SelectQueryBuilder.php └── TableAliasGenerator.php ├── tests-config.php └── tests ├── AbstractTestCase.php ├── GatewayNativeQueryTest.php ├── GatewayTest.php ├── Generated ├── ClassMetadata.php ├── Proxy │ ├── CountryProxy.php │ ├── Event │ │ ├── CountryEvent │ │ │ ├── CreateCountryEventProxy.php │ │ │ └── EditCountryNameEventProxy.php │ │ ├── FollowUserEventProxy.php │ │ └── UserEvent │ │ │ ├── CreateUserEventProxy.php │ │ │ ├── EditUserBillingAddressEventProxy.php │ │ │ ├── EditUserDeliveryAddressEventProxy.php │ │ │ └── EditUserNameEventProxy.php │ ├── FollowProxy.php │ └── UserProxy.php └── Repository │ ├── CountryRepository.php │ ├── EventRepository.php │ ├── FollowRepository.php │ └── UserRepository.php ├── InheritanceTest.php ├── ObjectFactoryTest.php ├── ProxyTest.php ├── Resources ├── DTO │ ├── AddressDTO.php │ └── UserDTO.php ├── Mappings │ └── GeometryMapping.php ├── Models │ ├── Address.php │ ├── Country.php │ ├── Event.php │ ├── Event │ │ ├── CountryEvent.php │ │ ├── CountryEvent │ │ │ ├── CreateCountryEvent.php │ │ │ └── EditCountryNameEvent.php │ │ ├── FollowUserEvent.php │ │ ├── UserEvent.php │ │ └── UserEvent │ │ │ ├── CreateUserEvent.php │ │ │ ├── EditUserBillingAddressEvent.php │ │ │ ├── EditUserDeliveryAddressEvent.php │ │ │ └── EditUserNameEvent.php │ ├── Follow.php │ ├── GeoAddress.php │ └── User.php └── Objects │ └── Geometry.php └── TableAliasGeneratorTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | PSALM_PHP_VERSION: "8.3" 9 | COVERAGE_PHP_VERSION: "8.3" 10 | 11 | jobs: 12 | psalm: 13 | name: Psalm 14 | runs-on: ubuntu-22.04 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ env.PSALM_PHP_VERSION }} 24 | 25 | - name: Install composer dependencies 26 | uses: ramsey/composer-install@v3 27 | 28 | - name: Run Psalm 29 | run: vendor/bin/psalm --show-info=false --no-progress 30 | 31 | phpunit: 32 | name: PHPUnit 33 | runs-on: ubuntu-22.04 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | php-version: 39 | - "8.1" 40 | - "8.2" 41 | - "8.3" 42 | deps: 43 | - "highest" 44 | include: 45 | - php-version: "8.1" 46 | deps: "lowest" 47 | 48 | services: 49 | mysql: 50 | image: "mysql:8.0" 51 | ports: 52 | - "3306:3306" 53 | options: >- 54 | --health-cmd "mysqladmin ping --silent" 55 | env: 56 | MYSQL_ROOT_PASSWORD: password 57 | 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | 62 | - name: Setup PHP 63 | uses: shivammathur/setup-php@v2 64 | with: 65 | php-version: ${{ matrix.php-version }} 66 | coverage: pcov 67 | 68 | - name: Install composer dependencies 69 | uses: ramsey/composer-install@v3 70 | with: 71 | dependency-versions: ${{ matrix.deps }} 72 | 73 | - name: Run PHPUnit 74 | run: vendor/bin/phpunit --fail-on-skipped 75 | if: ${{ matrix.php-version != env.COVERAGE_PHP_VERSION }} 76 | env: 77 | DB_HOST: 127.0.0.1 78 | DB_PORT: 3306 79 | DB_USERNAME: root 80 | DB_PASSWORD: password 81 | 82 | - name: Run PHPUnit with coverage 83 | run: | 84 | mkdir -p build/logs 85 | vendor/bin/phpunit --fail-on-skipped --coverage-clover build/logs/clover.xml 86 | if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }} 87 | env: 88 | DB_HOST: 127.0.0.1 89 | DB_PORT: 3306 90 | DB_USERNAME: root 91 | DB_PASSWORD: password 92 | 93 | - name: Upload coverage report to Coveralls 94 | run: vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml -v 95 | env: 96 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.cache 4 | -------------------------------------------------------------------------------- /bin/orm-build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | build(); 62 | 63 | $php = 'getClassMetadataFile(); 66 | echo $path, PHP_EOL; 67 | 68 | file_put_contents($path, $php); 69 | 70 | return $classMetadata; 71 | })(); 72 | 73 | // Build repositories for all aggregate roots 74 | 75 | (function() use ($configuration, $classMetadata) { 76 | foreach ($configuration->getEntities() as $className => $entityConfiguration) { 77 | if ($entityConfiguration->getBelongsTo() !== null) { 78 | // Only generate repositories for aggregate roots. 79 | continue; 80 | } 81 | 82 | $builder = new RepositoryBuilder(); 83 | $builder->setRepositoryNamespace($configuration->getRepositoryNamespace($className)); 84 | $builder->setEntityClassName($className); 85 | 86 | $identityProps = []; 87 | 88 | foreach ($classMetadata[$className]->idProperties as $idProperty) { 89 | $type = $classMetadata[$className]->propertyMappings[$idProperty]->getType(); 90 | 91 | if ($type === null) { 92 | throw new \LogicException('An identity property cannot be mapped to a mixed type.'); 93 | } 94 | 95 | $identityProps[$idProperty] = $type; 96 | } 97 | 98 | $builder->setIdentityProps($identityProps); 99 | 100 | $path = $configuration->getRepositoryFileName($className); 101 | echo $path, PHP_EOL; 102 | 103 | @mkdir(dirname($path), 0777, true); 104 | file_put_contents($path, $builder->build()); 105 | } 106 | })(); 107 | 108 | // Build proxies for all non-abstract entity classes 109 | 110 | (function() use ($configuration, $classMetadata) { 111 | foreach ($configuration->getEntities() as $entityConfiguration) { 112 | foreach ($entityConfiguration->getClassHierarchy() as $concreteClassName) { 113 | $reflectionClass = new \ReflectionClass($concreteClassName); 114 | 115 | if ($reflectionClass->isAbstract()) { 116 | continue; 117 | } 118 | 119 | $builder = new ProxyBuilder(); 120 | $builder->setProxyNamespace($configuration->getProxyNamespace($concreteClassName)); 121 | $builder->setEntityClassName($concreteClassName); 122 | $builder->setNonIdProps($classMetadata[$concreteClassName]->nonIdProperties); 123 | 124 | $path = $configuration->getProxyFileName($concreteClassName); 125 | 126 | echo $path, PHP_EOL; 127 | 128 | @mkdir(dirname($path), 0777, true); 129 | file_put_contents($path, $builder->build()); 130 | } 131 | } 132 | })(); 133 | })($argv[1]); 134 | 135 | echo 'Success.', PHP_EOL; 136 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brick/orm", 3 | "description": "Object-Relational Mapper", 4 | "type": "library", 5 | "keywords": [ 6 | "ORM", 7 | "Data Mapper" 8 | ], 9 | "license": "MIT", 10 | "require": { 11 | "php": "^8.1", 12 | "brick/db": "dev-interfaces" 13 | }, 14 | "require-dev": { 15 | "ext-pdo": "*", 16 | "phpunit/phpunit": "^10.1", 17 | "php-coveralls/php-coveralls": "^2.0", 18 | "vimeo/psalm": "5.24.0" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Brick\\ORM\\": "src/" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Brick\\ORM\\Tests\\": "tests/" 28 | } 29 | }, 30 | "bin": ["bin/orm-build"] 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ]]> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | entities[$class]]]> 18 | entities[$class]]]> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/ClassConfiguration.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 23 | 24 | try { 25 | $this->reflectionClass = new \ReflectionClass($className); 26 | } catch (\ReflectionException $e) { 27 | throw new \InvalidArgumentException(sprintf('%s does not exist.', $className), 0, $e); 28 | } 29 | } 30 | 31 | /** 32 | * @psalm-return class-string 33 | */ 34 | public function getClassName() : string 35 | { 36 | return $this->reflectionClass->getName(); 37 | } 38 | 39 | /** 40 | * @psalm-param class-string|null $className 41 | * 42 | * @psalm-return list 43 | * 44 | * @param string|null $className The entity class name, or null to use the root entity (this entity)'s class name. 45 | * 46 | * @return string[] 47 | * 48 | * @throws \LogicException 49 | */ 50 | public function getPersistentProperties(string|null $className = null) : array 51 | { 52 | if ($className === null) { 53 | $reflectionClass = $this->reflectionClass; 54 | } else { 55 | $reflectionClass = new \ReflectionClass($className); 56 | } 57 | 58 | $className = $reflectionClass->getName(); 59 | 60 | $persistableProperties = []; 61 | 62 | foreach ($reflectionClass->getProperties() as $reflectionProperty) { 63 | if ($reflectionProperty->isStatic()) { 64 | continue; 65 | } 66 | 67 | $propertyName = $reflectionProperty->getName(); 68 | 69 | if (in_array($propertyName, $this->configuration->getTransientProperties($className))) { 70 | continue; 71 | } 72 | 73 | if ($reflectionProperty->isPrivate()) { 74 | throw new \LogicException(sprintf('%s::$%s is private; private properties are not supported. Make the property protected, or add it to transient properties if it should not be persistent.', $className, $propertyName)); 75 | } 76 | 77 | if (! $reflectionProperty->hasType()) { 78 | throw new \LogicException(sprintf('%s::$%s is not typed. Add a type to the property, or add it to transient properties if it should not be persistent.', $className, $propertyName)); 79 | } 80 | 81 | $persistableProperties[] = $propertyName; 82 | } 83 | 84 | if (count($persistableProperties) === 0) { 85 | throw new \LogicException(sprintf('%s has not persistable properties.', $className)); 86 | } 87 | 88 | return $persistableProperties; 89 | } 90 | 91 | /** 92 | * @psalm-param class-string $className 93 | * 94 | * @param string $className The entity class name. 95 | * @param string $propertyName The property name. 96 | * @param EntityMetadata[] $entityMetadata A map of FQCN to EntityMetadata instances. 97 | * @param EmbeddableMetadata[] $embeddableMetadata A map of FQCN to EmbeddableMetadata instances. 98 | * 99 | * @throws \LogicException 100 | */ 101 | public function getPropertyMapping(string $className, string $propertyName, array $entityMetadata, array $embeddableMetadata) : PropertyMapping 102 | { 103 | if (! in_array($propertyName, $this->getPersistentProperties($className))) { 104 | throw new \InvalidArgumentException(sprintf('Cannot return property mapping for unknown or non-persistent property %s::$%s.', $className, $propertyName)); 105 | } 106 | 107 | $customPropertyMappings = $this->configuration->getCustomPropertyMappings(); 108 | 109 | if (isset($customPropertyMappings[$className][$propertyName])) { 110 | return $customPropertyMappings[$className][$propertyName]; 111 | } 112 | 113 | $reflectionProperty = new \ReflectionProperty($className, $propertyName); 114 | 115 | $propertyType = $reflectionProperty->getType(); 116 | 117 | if (! $propertyType instanceof ReflectionNamedType) { 118 | throw new \LogicException('Property does not have a single type.'); 119 | } 120 | 121 | $typeName = $propertyType->getName(); 122 | $allowsNull = $propertyType->allowsNull(); 123 | 124 | $fieldNames = $this->configuration->getFieldNames(); 125 | $fieldName = $fieldNames[$className][$propertyName] ?? $propertyName; 126 | 127 | if ($propertyType->isBuiltin()) { 128 | return match($typeName) { 129 | 'int' => new PropertyMapping\IntMapping($fieldName, $allowsNull), 130 | 'string' => new PropertyMapping\StringMapping($fieldName, $allowsNull), 131 | 'bool' => new PropertyMapping\BoolMapping($fieldName, $allowsNull), 132 | 'array' => throw new \LogicException(sprintf('Cannot persist type "array" in %s::$%s; you can store an array as JSON if you wish, by configuring a custom JsonMapping instance.', $className, $propertyName)), 133 | default => throw new \LogicException(sprintf('Cannot persist type "%s" in %s::$%s.', $typeName, $className, $propertyName)) 134 | }; 135 | } 136 | 137 | $customMappings = $this->configuration->getCustomMappings(); 138 | 139 | if (isset($customMappings[$typeName])) { 140 | // @todo for now this only works with a single field name/prefix, and fixed constructor 141 | return new $customMappings[$typeName]($fieldName, $allowsNull); 142 | } 143 | 144 | $fieldNamePrefixes = $this->configuration->getFieldNamePrefixes(); 145 | $fieldNamePrefix = $fieldNamePrefixes[$className][$propertyName] ?? $propertyName . '_'; 146 | 147 | if (isset($entityMetadata[$typeName])) { 148 | return new PropertyMapping\EntityMapping($entityMetadata[$typeName], $fieldNamePrefix, $allowsNull); 149 | } 150 | 151 | if (isset($embeddableMetadata[$typeName])) { 152 | return new PropertyMapping\EmbeddableMapping($embeddableMetadata[$typeName], $fieldNamePrefix, $allowsNull); 153 | } 154 | 155 | throw new \LogicException(sprintf('Type %s of %s::$%s is not an entity or embeddable, and has no custom mapping defined.', $typeName, $className, $propertyName)); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/ClassMetadata.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @var string[] 22 | */ 23 | public array $properties; 24 | 25 | /** 26 | * A map of persistent property names to PropertyMapping instances. 27 | * 28 | * The keys of this array must be equal to $properties. 29 | * 30 | * @var array 31 | */ 32 | public array $propertyMappings; 33 | } 34 | -------------------------------------------------------------------------------- /src/ClassMetadataBuilder.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * @var EntityMetadata[] 24 | */ 25 | private array $entityMetadata; 26 | 27 | /** 28 | * @psalm-var array 29 | * 30 | * @var EmbeddableMetadata[] 31 | */ 32 | private array $embeddableMetadata; 33 | 34 | public function __construct(Configuration $configuration) 35 | { 36 | $this->configuration = $configuration; 37 | } 38 | 39 | /** 40 | * @psalm-return array 41 | * 42 | * @return EntityMetadata[] A map of FQCN to EntityMetadata instances for all entities. 43 | */ 44 | public function build() : array 45 | { 46 | $this->entityMetadata = []; 47 | $this->embeddableMetadata = []; 48 | 49 | $classConfigurations = $this->configuration->getClasses(); 50 | 51 | foreach ($classConfigurations as $classConfiguration) { 52 | if ($classConfiguration instanceof EntityConfiguration) { 53 | foreach ($classConfiguration->getClassHierarchy() as $className) { 54 | $this->entityMetadata[$className] = new EntityMetadata(); 55 | } 56 | } elseif ($classConfiguration instanceof EmbeddableConfiguration) { 57 | $this->embeddableMetadata[$classConfiguration->getClassName()] = new EmbeddableMetadata(); 58 | } 59 | } 60 | 61 | // This needs to be done in 2 steps, as references to all ClassMetadata instances must be available below. 62 | 63 | foreach ($classConfigurations as $classConfiguration) { 64 | if ($classConfiguration instanceof EntityConfiguration) { 65 | foreach ($classConfiguration->getClassHierarchy() as $className) { 66 | $this->fillEntityMetadata($this->entityMetadata[$className], $className, $classConfiguration); 67 | } 68 | } elseif ($classConfiguration instanceof EmbeddableConfiguration) { 69 | $className = $classConfiguration->getClassName(); 70 | $this->fillEmbeddableMetadata($this->embeddableMetadata[$className], $className, $classConfiguration); 71 | } 72 | } 73 | 74 | return $this->entityMetadata; 75 | } 76 | 77 | /** 78 | * @psalm-param class-string $className 79 | * 80 | * @throws \LogicException 81 | */ 82 | private function fillEntityMetadata(EntityMetadata $classMetadata, string $className, EntityConfiguration $entityConfiguration) : void 83 | { 84 | $reflectionClass = new \ReflectionClass($className); 85 | 86 | $classMetadata->className = $className; 87 | 88 | $classMetadata->discriminatorColumn = $entityConfiguration->getDiscriminatorColumn(); 89 | $classMetadata->discriminatorValue = null; 90 | 91 | foreach ($entityConfiguration->getDiscriminatorMap() as $discriminatorValue => $targetClassName) { 92 | if ($targetClassName === $className) { 93 | $classMetadata->discriminatorValue = $discriminatorValue; 94 | break; 95 | } 96 | } 97 | 98 | $classMetadata->discriminatorMap = $entityConfiguration->getDiscriminatorMap(); 99 | $classMetadata->inverseDiscriminatorMap = array_flip($classMetadata->discriminatorMap); 100 | 101 | $classMetadata->childClasses = []; 102 | 103 | foreach ($entityConfiguration->getClassHierarchy() as $hClassName) { 104 | if (is_subclass_of($hClassName, $className)) { 105 | $classMetadata->childClasses[] = $hClassName; 106 | } 107 | } 108 | 109 | $classMetadata->rootClassName = $entityConfiguration->getClassName(); 110 | 111 | if ($reflectionClass->isAbstract()) { 112 | $classMetadata->proxyClassName = null; 113 | } else { 114 | $classMetadata->proxyClassName = $this->configuration->getProxyClassName($className); 115 | } 116 | 117 | $classMetadata->tableName = $entityConfiguration->getTableName(); 118 | $classMetadata->isAutoIncrement = $entityConfiguration->isAutoIncrement(); 119 | 120 | $persistentProperties = $entityConfiguration->getPersistentProperties($className); 121 | $identityProperties = $entityConfiguration->getIdentityProperties(); 122 | 123 | $classMetadata->properties = $persistentProperties; 124 | $classMetadata->idProperties = $identityProperties; 125 | $classMetadata->nonIdProperties = array_values(array_diff($persistentProperties, $identityProperties)); 126 | 127 | $classMetadata->selfNonIdProperties = []; 128 | 129 | foreach ($classMetadata->nonIdProperties as $nonIdProperty) { 130 | $r = new \ReflectionProperty($className, $nonIdProperty); 131 | if ($r->getDeclaringClass()->getName() === $className) { 132 | $classMetadata->selfNonIdProperties[] = $nonIdProperty; 133 | } 134 | } 135 | 136 | $classMetadata->propertyMappings = []; 137 | 138 | foreach ($persistentProperties as $propertyName) { 139 | $propertyMapping = $entityConfiguration->getPropertyMapping($className, $propertyName, $this->entityMetadata, $this->embeddableMetadata); 140 | $classMetadata->propertyMappings[$propertyName] = $propertyMapping; 141 | } 142 | 143 | // Enforce non-nullable identities, that ultimately map to int or string properties. 144 | // We need this guarantee for our identity map, and other types do not make much sense anyway. 145 | 146 | foreach ($classMetadata->idProperties as $idProperty) { 147 | $propertyMapping = $classMetadata->propertyMappings[$idProperty]; 148 | 149 | if ($propertyMapping->isNullable()) { 150 | throw new \LogicException(sprintf( 151 | 'Identity property %s::$%s must not be nullable.', 152 | $classMetadata->className, 153 | $idProperty 154 | )); 155 | } 156 | 157 | if ($propertyMapping instanceof IntMapping) { 158 | continue; 159 | } 160 | 161 | if ($propertyMapping instanceof StringMapping) { 162 | continue; 163 | } 164 | 165 | if ($propertyMapping instanceof EntityMapping) { 166 | continue; 167 | } 168 | 169 | throw new \LogicException(sprintf( 170 | 'Identity property %s::$%s uses an unsupported mapping type %s. ' . 171 | 'Identities must ultimately map to int or string properties.', 172 | $classMetadata->className, 173 | $idProperty, 174 | get_class($propertyMapping) 175 | )); 176 | } 177 | } 178 | 179 | /** 180 | * @psalm-param class-string $className 181 | */ 182 | private function fillEmbeddableMetadata(EmbeddableMetadata $classMetadata, string $className, EmbeddableConfiguration $embeddableConfiguration) : void 183 | { 184 | $classMetadata->className = $className; 185 | 186 | $persistentProperties = $embeddableConfiguration->getPersistentProperties($className); 187 | 188 | $classMetadata->properties = $persistentProperties; 189 | 190 | $classMetadata->propertyMappings = []; 191 | 192 | foreach ($persistentProperties as $propertyName) { 193 | $propertyMapping = $embeddableConfiguration->getPropertyMapping($className, $propertyName, $this->entityMetadata, $this->embeddableMetadata); 194 | $classMetadata->propertyMappings[$propertyName] = $propertyMapping; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | 23 | * 24 | * @var ClassConfiguration[] 25 | */ 26 | private array $classes = []; 27 | 28 | /** 29 | * A map of entity/embeddable class names to lists of transient property names. 30 | * 31 | * @psalm-var array> 32 | * 33 | * @var string[][] 34 | */ 35 | private array $transientProperties = []; 36 | 37 | /** 38 | * A map of entity/embeddable class names to property names to PropertyMapping instances. 39 | * 40 | * The mappings are usually inferred from the PHP type, but can be overridden here. 41 | * This is typically used to map a mixed/array type property to a JSON column. 42 | * 43 | * @psalm-var array> 44 | * 45 | * @var PropertyMapping[][] 46 | */ 47 | private array $customPropertyMappings = []; 48 | 49 | /** 50 | * A map of entity/embeddable class names to property names to field names. 51 | * 52 | * @psalm-var array> 53 | * 54 | * @var string[][] 55 | */ 56 | private array $fieldNames = []; 57 | 58 | /** 59 | * A map of entity/embeddable class names to property names to field name prefixes. 60 | * 61 | * @psalm-var array> 62 | * 63 | * @var string[][] 64 | */ 65 | private array $fieldNamePrefixes = []; 66 | 67 | /** 68 | * A map of class names to custom property mapping classes. 69 | * 70 | * @var array> 71 | */ 72 | private array $customMappings = []; 73 | 74 | public function setProxyNamespace(string $proxyNamespace) : Configuration 75 | { 76 | $this->proxyNamespace = $proxyNamespace; 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * @throws \LogicException 83 | */ 84 | public function getProxyNamespace(string|null $entityClass = null) : string 85 | { 86 | if ($this->proxyNamespace === null) { 87 | throw new \LogicException('Proxy namespace is not set.'); 88 | } 89 | 90 | if ($entityClass === null) { 91 | return $this->proxyNamespace; 92 | } 93 | 94 | if ($this->baseEntityNamespace !== null) { 95 | $baseNamespace = $this->baseEntityNamespace . '\\'; 96 | $length = strlen($baseNamespace); 97 | 98 | if (substr($entityClass, 0, $length) !== $baseNamespace) { 99 | throw new \LogicException(sprintf('%s is not in namespace %s.', $entityClass, $this->baseEntityNamespace)); 100 | } 101 | 102 | $entityClass = substr($entityClass, $length); 103 | } 104 | 105 | $pos = strrpos($entityClass, '\\'); 106 | 107 | if ($pos === false) { 108 | return $this->proxyNamespace; 109 | } 110 | 111 | return $this->proxyNamespace . '\\' . substr($entityClass, 0, $pos); 112 | } 113 | 114 | /** 115 | * Returns the proxy class name for the given entity class name. 116 | * 117 | * @psalm-param class-string $entityClass 118 | * 119 | * @psalm-return class-string 120 | * 121 | * @psalm-suppress LessSpecificReturnStatement 122 | * @psalm-suppress MoreSpecificReturnType 123 | * 124 | * @param string $entityClass the FQCN of the entity. 125 | * 126 | * @return string The FQCN of the proxy. 127 | * 128 | * @throws \LogicException 129 | */ 130 | public function getProxyClassName(string $entityClass) : string 131 | { 132 | if ($this->baseEntityNamespace !== null) { 133 | $baseNamespace = $this->baseEntityNamespace . '\\'; 134 | $length = strlen($baseNamespace); 135 | 136 | if (substr($entityClass, 0, $length) !== $baseNamespace) { 137 | throw new \LogicException(sprintf('%s is not in namespace %s.', $entityClass, $this->baseEntityNamespace)); 138 | } 139 | 140 | $entityClass = substr($entityClass, $length); 141 | } 142 | 143 | return $this->getProxyNamespace() . '\\' . $entityClass . 'Proxy'; 144 | } 145 | 146 | public function getProxyFileName(string $entityClass) : string 147 | { 148 | if ($this->baseEntityNamespace !== null) { 149 | $baseNamespace = $this->baseEntityNamespace . '\\'; 150 | $length = strlen($baseNamespace); 151 | 152 | if (substr($entityClass, 0, $length) !== $baseNamespace) { 153 | throw new \LogicException(sprintf('%s is not in namespace %s.', $entityClass, $this->baseEntityNamespace)); 154 | } 155 | 156 | $entityClass = substr($entityClass, $length); 157 | } 158 | 159 | return $this->getProxyDir() . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $entityClass) . 'Proxy.php'; 160 | } 161 | 162 | public function setProxyDir(string $proxyDir) : Configuration 163 | { 164 | $this->proxyDir = $proxyDir; 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * @throws \LogicException 171 | */ 172 | public function getProxyDir() : string 173 | { 174 | if ($this->proxyDir === null) { 175 | throw new \LogicException('Proxy dir is not set.'); 176 | } 177 | 178 | return $this->proxyDir; 179 | } 180 | 181 | public function setRepositoryNamespace(string $repositoryNamespace) : Configuration 182 | { 183 | $this->repositoryNamespace = $repositoryNamespace; 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * @throws \LogicException 190 | */ 191 | public function getRepositoryNamespace(string|null $entityClass = null) : string 192 | { 193 | if ($this->repositoryNamespace === null) { 194 | throw new \LogicException('Repository namespace is not set.'); 195 | } 196 | 197 | if ($entityClass === null) { 198 | return $this->repositoryNamespace; 199 | } 200 | 201 | if ($this->baseEntityNamespace !== null) { 202 | $baseNamespace = $this->baseEntityNamespace . '\\'; 203 | $length = strlen($baseNamespace); 204 | 205 | if (substr($entityClass, 0, $length) !== $baseNamespace) { 206 | throw new \LogicException(sprintf('%s is not in namespace %s.', $entityClass, $this->baseEntityNamespace)); 207 | } 208 | 209 | $entityClass = substr($entityClass, $length); 210 | } 211 | 212 | $pos = strrpos($entityClass, '\\'); 213 | 214 | if ($pos === false) { 215 | return $this->repositoryNamespace; 216 | } 217 | 218 | return $this->repositoryNamespace . '\\' . substr($entityClass, 0, $pos); 219 | } 220 | 221 | public function getRepositoryFileName(string $entityClass) : string 222 | { 223 | if ($this->baseEntityNamespace !== null) { 224 | $baseNamespace = $this->baseEntityNamespace . '\\'; 225 | $length = strlen($baseNamespace); 226 | 227 | if (substr($entityClass, 0, $length) !== $baseNamespace) { 228 | throw new \LogicException(sprintf('%s is not in namespace %s.', $entityClass, $this->baseEntityNamespace)); 229 | } 230 | 231 | $entityClass = substr($entityClass, $length); 232 | } 233 | 234 | return $this->getRepositoryDir() . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $entityClass) . 'Repository.php'; 235 | } 236 | 237 | public function setRepositoryDir(string $repositoryDir) : Configuration 238 | { 239 | $this->repositoryDir = $repositoryDir; 240 | 241 | return $this; 242 | } 243 | 244 | /** 245 | * @throws \LogicException 246 | */ 247 | public function getRepositoryDir() : string 248 | { 249 | if ($this->repositoryDir === null) { 250 | throw new \LogicException('Repository dir is not set.'); 251 | } 252 | 253 | return $this->repositoryDir; 254 | } 255 | 256 | /** 257 | * Sets the path to the PHP file where the ClassMetadata will be stored. 258 | */ 259 | public function setClassMetadataFile(string $classMetadataFile) : Configuration 260 | { 261 | if (substr($classMetadataFile, -4) !== '.php') { 262 | throw new \InvalidArgumentException('The ClassMetadata file path must have a .php extension.'); 263 | } 264 | 265 | $this->classMetadataFile = $classMetadataFile; 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * @throws \LogicException 272 | */ 273 | public function getClassMetadataFile() : string 274 | { 275 | if ($this->classMetadataFile === null) { 276 | throw new \LogicException('ClassMetadata file path is not set.'); 277 | } 278 | 279 | return $this->classMetadataFile; 280 | } 281 | 282 | /** 283 | * Sets the base namespace all entities live in. 284 | * 285 | * This is optional, but restricts the number of sub-namespaces (and subdirs) created for repositories and proxies. 286 | * 287 | * For example, by default App\Model\User's repository would live in RepositoryNamespace\App\Model\UserRepository, 288 | * while with a base entity namespace of App\Model it would live in RepositoryNamespace\UserRepository. 289 | */ 290 | public function setBaseEntityNamespace(string $namespace) : Configuration 291 | { 292 | $this->baseEntityNamespace = $namespace; 293 | 294 | return $this; 295 | } 296 | 297 | public function getBaseEntityNamespace() : string|null 298 | { 299 | return $this->baseEntityNamespace; 300 | } 301 | 302 | /** 303 | * @psalm-param class-string $className 304 | */ 305 | public function addEntity(string $className) : EntityConfiguration 306 | { 307 | $entityConfiguration = new EntityConfiguration($this, $className); 308 | $this->classes[$className] = $entityConfiguration; 309 | 310 | return $entityConfiguration; 311 | } 312 | 313 | /** 314 | * @psalm-param class-string $className 315 | */ 316 | public function addEmbeddable(string $className) : EmbeddableConfiguration 317 | { 318 | $embeddableConfiguration = new EmbeddableConfiguration($this, $className); 319 | $this->classes[$className] = $embeddableConfiguration; 320 | 321 | return $embeddableConfiguration; 322 | } 323 | 324 | /** 325 | * Adds a custom mapping that applies by default to all properties of the given type. 326 | * 327 | * @psalm-param class-string $className 328 | * @psalm-param class-string $propertyMapping 329 | * 330 | * @param string $className The mapped class name. 331 | * @param string $propertyMapping The PropertyMapping implementation class name. 332 | */ 333 | public function addCustomMapping(string $className, string $propertyMapping) : Configuration 334 | { 335 | $this->customMappings[$className] = $propertyMapping; 336 | 337 | return $this; 338 | } 339 | 340 | /** 341 | * @psalm-return array> 342 | */ 343 | public function getCustomMappings() : array 344 | { 345 | return $this->customMappings; 346 | } 347 | 348 | /** 349 | * Adds a custom property mapping for a specific property of a given entity/embeddable class. 350 | * 351 | * @todo Naming of addCustomMapping() / setCustomPropertyMapping() is a bit confusing 352 | * 353 | * @psalm-param class-string $class 354 | */ 355 | public function setCustomPropertyMapping(string $class, string $property, PropertyMapping $mapping) : Configuration 356 | { 357 | $this->customPropertyMappings[$class][$property] = $mapping; 358 | 359 | return $this; 360 | } 361 | 362 | /** 363 | * @psalm-return array> 364 | * 365 | * @return PropertyMapping[][] 366 | */ 367 | public function getCustomPropertyMappings() : array 368 | { 369 | return $this->customPropertyMappings; 370 | } 371 | 372 | /** 373 | * @psalm-param class-string $class 374 | */ 375 | public function setTransientProperties(string $class, string ...$properties) : Configuration 376 | { 377 | $this->transientProperties[$class] = array_values($properties); 378 | 379 | return $this; 380 | } 381 | 382 | /** 383 | * Returns the list of transient properties for the given class name. 384 | * 385 | * @psalm-param class-string $class 386 | * 387 | * @psalm-return list 388 | * 389 | * @return string[] 390 | */ 391 | public function getTransientProperties(string $class) : array 392 | { 393 | return $this->transientProperties[$class] ?? []; 394 | } 395 | 396 | /** 397 | * @psalm-param class-string $class 398 | */ 399 | public function setFieldName(string $class, string $property, string $fieldName) : Configuration 400 | { 401 | $this->fieldNames[$class][$property] = $fieldName; 402 | 403 | return $this; 404 | } 405 | 406 | /** 407 | * Sets custom field names for builtin type properties. 408 | * 409 | * If not set, the field name defaults to the property name. 410 | * 411 | * @psalm-return array> 412 | * 413 | * @return string[][] 414 | */ 415 | public function getFieldNames() : array 416 | { 417 | return $this->fieldNames; 418 | } 419 | 420 | /** 421 | * Sets field name prefixes for entity/embeddable properties. 422 | * 423 | * If not set, the field name prefix defaults to the property name followed by an underscore character. 424 | * 425 | * @psalm-param class-string $class 426 | */ 427 | public function setFieldNamePrefix(string $class, string $property, string $fieldNamePrefix) : Configuration 428 | { 429 | $this->fieldNamePrefixes[$class][$property] = $fieldNamePrefix; 430 | 431 | return $this; 432 | } 433 | 434 | /** 435 | * @psalm-return array> 436 | * 437 | * @return string[][] 438 | */ 439 | public function getFieldNamePrefixes() : array 440 | { 441 | return $this->fieldNamePrefixes; 442 | } 443 | 444 | /** 445 | * Returns the class configurations, indexed by FQCN. 446 | * 447 | * @psalm-return array 448 | * 449 | * @return ClassConfiguration[] 450 | */ 451 | public function getClasses() : array 452 | { 453 | return $this->classes; 454 | } 455 | 456 | /** 457 | * Returns the entity configurations, indexed by FQCN. 458 | * 459 | * @psalm-return array 460 | * 461 | * @return EntityConfiguration[] 462 | */ 463 | public function getEntities() : array 464 | { 465 | $entityConfigurations = []; 466 | 467 | foreach ($this->classes as $className => $classConfiguration) { 468 | if ($classConfiguration instanceof EntityConfiguration) { 469 | $entityConfigurations[$className] = $classConfiguration; 470 | } 471 | } 472 | 473 | return $entityConfigurations; 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /src/EmbeddableConfiguration.php: -------------------------------------------------------------------------------- 1 | |null 21 | * 22 | * @var string[]|null 23 | */ 24 | private array|null $identityProperties = null; 25 | 26 | /** 27 | * The discriminator column name, or null if not set. 28 | */ 29 | private string|null $discriminatorColumn = null; 30 | 31 | /** 32 | * A map of discriminator values to entity class names, or an empty array if not set. 33 | * 34 | * @psalm-var array 35 | * 36 | * @var string[] 37 | */ 38 | private array $discriminatorMap = []; 39 | 40 | /** 41 | * Sets the root entity of the aggregate this entity belongs to. 42 | * 43 | * @psalm-param class-string $className 44 | */ 45 | public function belongsTo(string $className) : EntityConfiguration 46 | { 47 | $this->belongsTo = $className; 48 | 49 | return $this; 50 | } 51 | 52 | public function getBelongsTo() : string|null 53 | { 54 | return $this->belongsTo; 55 | } 56 | 57 | /** 58 | * Sets the table name. 59 | * 60 | * If not set, it will default to the entity short name (i.e. the name without the namespace). 61 | */ 62 | public function setTableName(string $tableName) : EntityConfiguration 63 | { 64 | $this->tableName = $tableName; 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Returns the table name. 71 | * 72 | * If not set, it will default to the entity short name (i.e. the name without the namespace). 73 | */ 74 | public function getTableName() : string 75 | { 76 | if ($this->tableName !== null) { 77 | return $this->tableName; 78 | } 79 | 80 | return $this->reflectionClass->getShortName(); 81 | } 82 | 83 | /** 84 | * Sets whether the database table uses an auto-increment identity field. 85 | */ 86 | public function setAutoIncrement() : EntityConfiguration 87 | { 88 | $this->isAutoIncrement = true; 89 | 90 | return $this; 91 | } 92 | 93 | /** 94 | * Returns whether the database table uses an auto-increment identity field. 95 | * 96 | * @throws \LogicException 97 | */ 98 | public function isAutoIncrement() : bool 99 | { 100 | if ($this->isAutoIncrement) { 101 | $identityProperties = $this->getIdentityProperties(); 102 | 103 | if (count($identityProperties) !== 1) { 104 | throw new \LogicException(sprintf( 105 | 'The entity "%s" has multiple identity properties and cannot be mapped to an auto-increment table.', 106 | $this->getClassName() 107 | )); 108 | } 109 | 110 | $reflectionProperty = $this->reflectionClass->getProperty($identityProperties[0]); 111 | 112 | $propertyType = $reflectionProperty->getType(); 113 | 114 | if ($propertyType instanceof ReflectionNamedType) { 115 | $type = $propertyType->getName(); 116 | 117 | if ($type !== 'int' && $type !== 'string') { 118 | throw new \LogicException(sprintf( 119 | 'The entity "%s" has an auto-increment identity that maps to an unsupported type "%s", ' . 120 | 'only int and string are allowed.', 121 | $this->getClassName(), 122 | $type 123 | )); 124 | } 125 | } else { 126 | throw new \LogicException(sprintf( 127 | 'The entity "%s" has an auto-increment identity that maps to an untyped or union type property, ' . 128 | 'only int and string are allowed.', 129 | $this->getClassName() 130 | )); 131 | } 132 | } 133 | 134 | return $this->isAutoIncrement; 135 | } 136 | 137 | /** 138 | * @throws \InvalidArgumentException 139 | */ 140 | public function setIdentityProperties(string ...$identityProperties) : EntityConfiguration 141 | { 142 | if (count($identityProperties) === 0) { 143 | throw new \InvalidArgumentException('The list of identity properties cannot be empty.'); 144 | } 145 | 146 | $identityProperties = array_values($identityProperties); 147 | 148 | $this->checkProperties($identityProperties); 149 | 150 | $this->identityProperties = $identityProperties; 151 | 152 | return $this; 153 | } 154 | 155 | /** 156 | * Returns the list of properties that are part of the entity's identity. 157 | * 158 | * @psalm-return list 159 | * 160 | * @return string[] 161 | * 162 | * @throws \LogicException 163 | */ 164 | public function getIdentityProperties() : array 165 | { 166 | if ($this->identityProperties === null) { 167 | throw new \LogicException(sprintf('No identity properties have been set for class %s.', $this->getClassName())); 168 | } 169 | 170 | foreach ($this->identityProperties as $identityProperty) { 171 | if (! in_array($identityProperty, $this->getPersistentProperties())) { 172 | throw new \LogicException(sprintf('Identity property $%s in class %s is not persistent.', $identityProperty, $this->getClassName())); 173 | } 174 | } 175 | 176 | return $this->identityProperties; 177 | } 178 | 179 | /** 180 | * Sets the inheritance mapping for this entity. 181 | * 182 | * Every persistable class in the hierarchy must have an entry in the discriminator map. This excludes abstract 183 | * classes, and root classes that are common to several entities (so-called MappedSuperclass in other ORM). 184 | * 185 | * Note: only single table inheritance is supported for now. 186 | * 187 | * @psalm-param array $discriminatorMap 188 | * 189 | * @param string $discriminatorColumn The discriminator column name. 190 | * @param array $discriminatorMap A map of discriminator value to concrete entity class name. 191 | * 192 | * @throws \InvalidArgumentException If the discriminator map is empty, a class name does not exist, or is not a subclass of the root entity class. 193 | */ 194 | public function setInheritanceMapping(string $discriminatorColumn, array $discriminatorMap) : EntityConfiguration 195 | { 196 | if (! $discriminatorMap) { 197 | throw new \InvalidArgumentException('The discriminator map cannot be empty.'); 198 | } 199 | 200 | $rootEntityClassName = $this->reflectionClass->getName(); 201 | 202 | foreach ($discriminatorMap as $discriminatorValue => $className) { 203 | try { 204 | $reflectionClass = new \ReflectionClass($className); 205 | } catch (\ReflectionException $e) { 206 | throw new \InvalidArgumentException(sprintf('%s does not exist.', $className), 0, $e); 207 | } 208 | 209 | if ($reflectionClass->isAbstract()) { 210 | throw new \InvalidArgumentException(sprintf('Abstract class %s cannot be part of the discriminator map.', $reflectionClass->getName())); 211 | } 212 | 213 | if ($reflectionClass->getName() !== $rootEntityClassName && ! $reflectionClass->isSubclassOf($rootEntityClassName)) { 214 | throw new \InvalidArgumentException(sprintf('%s is not a subclass of %s and cannot be part of its discriminator map.', $reflectionClass->getName(), $rootEntityClassName)); 215 | } 216 | 217 | // Override to fix potential wrong case 218 | $discriminatorMap[$discriminatorValue] = $reflectionClass->getName(); 219 | } 220 | 221 | // Check that values are unique 222 | if (count(array_unique($discriminatorMap)) !== count($discriminatorMap)) { 223 | throw new \InvalidArgumentException('Duplicate class names in discriminator map.'); 224 | } 225 | 226 | $this->discriminatorColumn = $discriminatorColumn; 227 | $this->discriminatorMap = $discriminatorMap; 228 | 229 | return $this; 230 | } 231 | 232 | /** 233 | * Returns the discriminator column name, or null if inheritance is not in use. 234 | */ 235 | public function getDiscriminatorColumn() : string|null 236 | { 237 | return $this->discriminatorColumn; 238 | } 239 | 240 | /** 241 | * Returns a map of discriminator values to fully-qualified entity class names. 242 | * 243 | * If no inheritance is mapped, an empty array is returned. 244 | * 245 | * @psalm-return array 246 | */ 247 | public function getDiscriminatorMap() : array 248 | { 249 | return $this->discriminatorMap; 250 | } 251 | 252 | /** 253 | * Returns the list of classes part of the hierarchy, starting with the root class (this entity). 254 | * 255 | * If this entity is part of an inheritance hierarchy, the result includes all the classes in the discriminator map, 256 | * plus any abstract class present between the root class and these classes. 257 | * 258 | * If this entity is not part of an inheritance hierarchy, an array with a single ReflectionClass instance, for this 259 | * entity, is returned. 260 | * 261 | * @psalm-return class-string[] 262 | * 263 | * @return string[] The list of all class names in the hierarchy. 264 | */ 265 | public function getClassHierarchy() : array 266 | { 267 | $classes = [ 268 | $this->getClassName() // root entity 269 | ]; 270 | 271 | foreach ($this->discriminatorMap as $className) { 272 | $reflectionClass = new \ReflectionClass($className); 273 | 274 | while ($reflectionClass->getName() !== $this->getClassName()) { 275 | $classes[] = $reflectionClass->getName(); 276 | $reflectionClass = $reflectionClass->getParentClass(); 277 | } 278 | } 279 | 280 | return array_values(array_unique($classes)); 281 | } 282 | 283 | /** 284 | * @psalm-param list $properties 285 | * 286 | * @param string[] $properties The list of property names to check. 287 | * 288 | * @throws \InvalidArgumentException If a property does not exist. 289 | */ 290 | private function checkProperties(array $properties) : void 291 | { 292 | foreach ($properties as $property) { 293 | try { 294 | $reflectionProperty = $this->reflectionClass->getProperty($property); 295 | } catch (\ReflectionException $e) { 296 | throw new \InvalidArgumentException(sprintf('Class %s has no property named $%s.', $this->getClassName(), $property), 0, $e); 297 | } 298 | 299 | if ($reflectionProperty->isStatic()) { 300 | throw new \InvalidArgumentException(sprintf('%s::$%s is static; static properties cannot be persisted.', $this->getClassName(), $property)); 301 | } 302 | 303 | if ($reflectionProperty->isPrivate()) { 304 | throw new \InvalidArgumentException(sprintf('%s::$%s is private; private properties are not supported.', $this->getClassName(), $property)); 305 | } 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/EntityMetadata.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | public array $discriminatorMap; 36 | 37 | /** 38 | * The inverse discriminator map. 39 | * 40 | * The keys are class names, the values are discriminator values (as string or int). 41 | * If the entity is not part of an inheritance hierarchy, this will be an empty array. 42 | * 43 | * This property is set, and is the same, for all classes in the inheritance hiearchy. 44 | * 45 | * @psalm-var array 46 | */ 47 | public array $inverseDiscriminatorMap; 48 | 49 | /** 50 | * The root entity class name. 51 | * 52 | * If the entity is not part of an inheritance hierarchy, or is itself the root of the hierarchy, this will be the 53 | * same as the entity class name. 54 | * 55 | * @psalm-var class-string 56 | */ 57 | public string $rootClassName; 58 | 59 | /** 60 | * The entity's proxy class name. 61 | * 62 | * This property is only set if the class is a concrete entity. 63 | * For abstract entities, this property will be null. 64 | * 65 | * @psalm-var class-string|null 66 | */ 67 | public string|null $proxyClassName; 68 | 69 | /** 70 | * The database table name. 71 | */ 72 | public string $tableName; 73 | 74 | /** 75 | * The list of persistent property names that are part of the identity. 76 | * 77 | * This list must not intersect with $nonIdProperties. 78 | * The union of $idProperties and $nonIdProperties must be equal to $properties. 79 | * 80 | * @psalm-var list 81 | * 82 | * @var string[] 83 | */ 84 | public array $idProperties; 85 | 86 | /** 87 | * The list of persistent property names that are NOT part of the identity. 88 | * 89 | * This list must not intersect with $idProperties. 90 | * The union of $idProperties and $nonIdProperties must be equal to $properties. 91 | * 92 | * @psalm-var list 93 | * 94 | * @var string[] 95 | */ 96 | public array $nonIdProperties; 97 | 98 | /** 99 | * The list of persistent property names that are not part of the identity, and are declared in this class only. 100 | * 101 | * Properties declared in parent classes are not included here. 102 | * 103 | * @psalm-var list 104 | * 105 | * @var string[] 106 | */ 107 | public array $selfNonIdProperties; 108 | 109 | /** 110 | * The list of child entity class names, if any. 111 | * 112 | * @psalm-var list 113 | * 114 | * @var string[] 115 | */ 116 | public array $childClasses; 117 | 118 | /** 119 | * Whether the table uses an auto-increment primary key. 120 | * 121 | * This is only supported on tables with a single primary key column. If this is true, there must be only one 122 | * property part of the identity, and this property must map to a single database field. 123 | */ 124 | public bool $isAutoIncrement; 125 | } 126 | -------------------------------------------------------------------------------- /src/Exception/EntityNotFoundException.php: -------------------------------------------------------------------------------- 1 | $scalarIdentity 18 | * 19 | * @param string $className The entity class name. 20 | * @param array $scalarIdentity The identity, as a list of int or string values. 21 | */ 22 | public static function entityNotFound(string $className, array $scalarIdentity) : self 23 | { 24 | return new self(sprintf( 25 | 'The entity %s with identity %s was not found.', 26 | $className, 27 | self::exportScalarIdentity($scalarIdentity) 28 | )); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exception/IdentityConflictException.php: -------------------------------------------------------------------------------- 1 | $scalarIdentity 15 | * 16 | * @param string $className The entity class name. 17 | * @param array $scalarIdentity The identity, as a list of scalar values. 18 | */ 19 | public static function identityMapConflict(string $className, array $scalarIdentity) : self 20 | { 21 | return new self(sprintf( 22 | 'The instance of entity type %s with identity %s cannot be added to the identity map, ' . 23 | 'because another instance of this type with the same identity already exists.', 24 | $className, 25 | self::exportScalarIdentity($scalarIdentity) 26 | )); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exception/NoIdentityException.php: -------------------------------------------------------------------------------- 1 | $scalarIdentity 38 | * 39 | * @param array $scalarIdentity The identity, as a list of scalar values. Must contain at least one entry. 40 | * Each entry must be an int or a string. 41 | */ 42 | protected static function exportScalarIdentity(array $scalarIdentity) : string 43 | { 44 | $result = []; 45 | 46 | foreach ($scalarIdentity as $value) { 47 | if (is_string($value)) { 48 | $result[] = self::exportString($value); 49 | } else { 50 | $result[] = (string) $value; 51 | } 52 | } 53 | 54 | $result = implode(', ', $result); 55 | 56 | if (count($scalarIdentity) === 1) { 57 | return $result; 58 | } 59 | 60 | return '[' . $result . ']'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Exception/UnknownEntityClassException.php: -------------------------------------------------------------------------------- 1 | $identity 37 | * 38 | * @param string $class The root entity class name. 39 | * @param array $identity The list of scalar values that form the entity's identity. 40 | * 41 | * @return object|null The entity, or null if not found. 42 | */ 43 | public function get(string $class, array $identity) : object|null 44 | { 45 | $ref = & $this->entities[$class]; 46 | 47 | foreach ($identity as $key) { 48 | $ref = & $ref[$key]; 49 | } 50 | 51 | return $ref; 52 | } 53 | 54 | /** 55 | * Adds an entity to the identity map. 56 | * 57 | * If the entity already exists in the identity map, this method does nothing. 58 | * If another entity already exists under this identity, an exception is thrown. 59 | * 60 | * @psalm-suppress MixedArrayAccess 61 | * 62 | * @psalm-param class-string $class 63 | * @psalm-param list $identity 64 | * 65 | * @param string $class The root entity class name. 66 | * @param array $identity The list of scalar values that form the entity's identity. 67 | * @param object $entity The entity to add. 68 | * 69 | * @throws IdentityConflictException If another instance with the same identity already exists. 70 | */ 71 | public function set(string $class, array $identity, object $entity) : void 72 | { 73 | $ref = & $this->entities[$class]; 74 | 75 | foreach ($identity as $key) { 76 | $ref = & $ref[$key]; 77 | } 78 | 79 | if ($ref !== null && $ref !== $entity) { 80 | throw IdentityConflictException::identityMapConflict($class, $identity); 81 | } 82 | 83 | $ref = $entity; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ObjectFactory.php: -------------------------------------------------------------------------------- 1 | 25 | * 26 | * @var ReflectionClass[] 27 | */ 28 | private array $classes = []; 29 | 30 | /** 31 | * A map of full qualified class name to map of property name to Closure. 32 | * 33 | * Each closure converts a property value to the correct type. 34 | * 35 | * @psalm-var array> 36 | * 37 | * @var Closure[][] 38 | */ 39 | private array $propertyConverters = []; 40 | 41 | /** 42 | * Instantiates an empty object, without calling the class constructor. 43 | * 44 | * By default, this method returns an object whose *persistent* properties are not initialized. 45 | * Transient properties are still initialized to their default value, if any. 46 | * Properties may be initialized by passing a map of property name to value. 47 | * 48 | * @psalm-param array $values 49 | * 50 | * @param ClassMetadata $classMetadata The class metadata of the entity or embeddable. 51 | * @param array $values An optional map of property name to value to write. 52 | * 53 | * @throws \ReflectionException If the class does not exist. 54 | */ 55 | public function instantiate(ClassMetadata $classMetadata, array $values = []) : object 56 | { 57 | $className = $classMetadata->className; 58 | 59 | if (isset($this->classes[$className])) { 60 | $reflectionClass = $this->classes[$className]; 61 | } else { 62 | $reflectionClass = $this->classes[$className] = new \ReflectionClass($className); 63 | } 64 | 65 | $object = $reflectionClass->newInstanceWithoutConstructor(); 66 | 67 | /** @psalm-suppress PossiblyNullFunctionCall bindTo() should never return null here */ 68 | (function() use ($classMetadata, $values, $reflectionClass) { 69 | // Unset persistent properties 70 | // @todo PHP 7.4: for even better performance, only unset typed properties that have a default value, as 71 | // unset() will have no effect on those that have no default value (will require a new metadata prop). 72 | foreach ($classMetadata->properties as $prop) { 73 | unset($this->{$prop}); 74 | } 75 | 76 | // Set values 77 | foreach ($values as $key => $value) { 78 | if ($value === null) { 79 | // @todo temporary fix: do not set null values when typed property is not nullable; 80 | // needs investigation to see why these null values are being passed in the first place 81 | 82 | $reflectionType = $reflectionClass->getProperty($key)->getType(); 83 | 84 | if ($reflectionType !== null && ! $reflectionType->allowsNull()) { 85 | continue; 86 | } 87 | } 88 | 89 | $this->{$key} = $value; 90 | } 91 | })->bindTo($object, $className)(); 92 | 93 | return $object; 94 | } 95 | 96 | /** 97 | * Instantiates a data transfer object with a nested array of scalar values. 98 | * 99 | * The class must have public properties only, and no constructor. 100 | * 101 | * @psalm-suppress MixedMethodCall We know that DTOs have no constructor. 102 | * 103 | * @template T 104 | * 105 | * @psalm-param class-string $className 106 | * @psalm-param array $values 107 | * 108 | * @psalm-return T 109 | * 110 | * @throws \ReflectionException If the class does not exist. 111 | * @throws \InvalidArgumentException If the class is not a valid DTO or an unexpected value is found. 112 | */ 113 | public function instantiateDTO(string $className, array $values) : object 114 | { 115 | $propertyConverters = $this->getPropertyConverters($className); 116 | 117 | $object = new $className; 118 | 119 | foreach ($values as $name => $value) { 120 | if (! isset($propertyConverters[$name])) { 121 | throw new \InvalidArgumentException(sprintf('There is no property named $%s in class %s.', $name, $className)); 122 | } 123 | 124 | $propertyConverter = $propertyConverters[$name]; 125 | $object->{$name} = $propertyConverter($value); 126 | } 127 | 128 | return $object; 129 | } 130 | 131 | /** 132 | * Returns the property converters for the given class, indexed by property name. 133 | * 134 | * @psalm-param class-string $className 135 | * 136 | * @psalm-return array 137 | * 138 | * @return Closure[] 139 | * 140 | * @throws \ReflectionException If the class does not exist. 141 | * @throws \InvalidArgumentException If the class is not a valid DTO or an unexpected value is found. 142 | */ 143 | private function getPropertyConverters(string $className) : array 144 | { 145 | if (isset($this->propertyConverters[$className])) { 146 | return $this->propertyConverters[$className]; 147 | } 148 | 149 | $reflectionClass = new \ReflectionClass($className); 150 | 151 | if ($reflectionClass->isAbstract()) { 152 | throw new \InvalidArgumentException(sprintf('Cannot instantiate abstract class %s.', $className)); 153 | } 154 | 155 | if ($reflectionClass->isInterface()) { 156 | throw new \InvalidArgumentException(sprintf('Cannot instantiate interface %s.', $className)); 157 | } 158 | 159 | if ($reflectionClass->isInternal()) { 160 | throw new \InvalidArgumentException(sprintf('Cannot instantiate internal class %s.', $className)); 161 | } 162 | 163 | if ($reflectionClass->getConstructor() !== null) { 164 | throw new \InvalidArgumentException(sprintf('Class %s must not have a constructor.', $className)); 165 | } 166 | 167 | $properties = $reflectionClass->getProperties(); 168 | 169 | $result = []; 170 | 171 | foreach ($properties as $property) { 172 | $name = $property->getName(); 173 | 174 | if ($property->isStatic()) { 175 | throw new \InvalidArgumentException(sprintf('Property $%s of class %s must not be static.', $name, $className)); 176 | } 177 | 178 | if (! $property->isPublic()) { 179 | throw new \InvalidArgumentException(sprintf('Property $%s of class %s must be public.', $name, $className)); 180 | } 181 | 182 | $result[$name] = $this->getPropertyValueConverter($property); 183 | } 184 | 185 | $this->propertyConverters[$className] = $result; 186 | 187 | return $result; 188 | } 189 | 190 | /** 191 | * @psalm-return Closure(mixed): mixed 192 | * 193 | * @psalm-suppress MissingClosureParamType 194 | * @psalm-suppress MissingClosureReturnType 195 | * 196 | * @throws \InvalidArgumentException If an unexpected value is found. 197 | */ 198 | private function getPropertyValueConverter(\ReflectionProperty $property) : Closure 199 | { 200 | $type = $property->getType(); 201 | 202 | $propertyName = $property->getName(); 203 | $className = $property->getDeclaringClass()->getName(); 204 | 205 | if ($type instanceof ReflectionNamedType) { 206 | $propertyType = $type->getName(); 207 | 208 | if ($type->isBuiltin()) { 209 | return match ($propertyType) { 210 | 'string' => fn ($value) => $value, 211 | 'int' => fn ($value) => (int) $value, 212 | 'float' => fn ($value) => (float) $value, 213 | 'bool' => fn ($value) => (bool) $value, 214 | default => throw new \InvalidArgumentException(sprintf('Unexpected non-scalar type "%s" for property $%s in class %s.', $propertyType, $propertyName, $className)) 215 | }; 216 | } 217 | 218 | return function($value) use ($propertyName, $className, $type) { 219 | if (! is_array($value)) { 220 | throw new \InvalidArgumentException(sprintf('Expected array for property $%s of class %s, got %s.', $propertyName, $className, gettype($value))); 221 | } 222 | 223 | /** @psalm-var array $value */ 224 | return $this->instantiateDTO($type->getName(), $value); 225 | }; 226 | } 227 | 228 | return fn ($value) => $value; 229 | } 230 | 231 | /** 232 | * Reads *initialized* object properties. 233 | * 234 | * Properties that are not initialized, or have been unset(), are not included in the array. 235 | * This method assumes that there are no private properties in parent classes. 236 | * 237 | * @psalm-suppress MixedInferredReturnType 238 | * @psalm-suppress MixedReturnStatement 239 | * 240 | * @psalm-return array 241 | * 242 | * @param object $object The object to read. 243 | * 244 | * @return array A map of property names to values. 245 | */ 246 | public function read(object $object) : array 247 | { 248 | /** @psalm-suppress PossiblyNullFunctionCall bindTo() should never return null here */ 249 | return (function() { 250 | return get_object_vars($this); 251 | })->bindTo($object, $object)(); 252 | } 253 | 254 | /** 255 | * Writes an object's properties. 256 | * 257 | * This method does not support writing private properties in parent classes. 258 | * 259 | * @psalm-param array $values 260 | * 261 | * @param object $object The object to write. 262 | * @param array $values A map of property names to values. 263 | */ 264 | public function write(object $object, array $values) : void 265 | { 266 | /** @psalm-suppress PossiblyNullFunctionCall bindTo() should never return null here */ 267 | (function() use ($values) { 268 | foreach ($values as $key => $value) { 269 | $this->{$key} = $value; 270 | } 271 | })->bindTo($object, $object)(); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 26 | * 27 | * @return string[] 28 | */ 29 | public function getFieldNames() : array; 30 | 31 | /** 32 | * Returns the number of database values required to compute the property value. 33 | * 34 | * This must return the number entries returned by getFieldToInputValuesSQL(). 35 | */ 36 | public function getInputValuesCount() : int; 37 | 38 | /** 39 | * Returns the SQL expressions to read each database value required to compute the property value. 40 | * 41 | * The input array will contain exactly one string for each field returned by getFieldNames(). 42 | * It will contain these field names, in the same order, with possible prefixes and quoting. 43 | * 44 | * The result array must contain exactly one string for each value required by convertInputValuesToProp(), in the 45 | * same order. 46 | * 47 | * This method may return more values that the number of field names, if several SQL functions have to be called on 48 | * a single field to load the property; for example, loading a Geometry could require selecting both ST_AsText() 49 | * and ST_SRID() on a single field. 50 | * 51 | * If no transformation is required, this method should return the input parameter unchanged. 52 | * 53 | * @psalm-param list $fieldNames 54 | * 55 | * @psalm-return list 56 | * 57 | * @param string[] $fieldNames The field names. 58 | * 59 | * @return string[] The list of fields to read, optionally wrapped with SQL code. 60 | */ 61 | public function getFieldToInputValuesSQL(array $fieldNames) : array; 62 | 63 | /** 64 | * Converts the given database values to a property value. 65 | * 66 | * The input array will contain one value for each getFieldToInputValuesSQL() entry, in the same order. 67 | * 68 | * @psalm-param list $values 69 | * 70 | * @param mixed[] $values The list of database values. 71 | * 72 | * @return mixed The property value. 73 | */ 74 | public function convertInputValuesToProp(Gateway $gateway, array $values) : mixed; 75 | 76 | /** 77 | * Converts the given property to SQL expressions and values for each database field it is mapped to. 78 | * 79 | * The result array must contain exactly one entry for each field returned by getFieldNames(), in the same order. 80 | * Each entry must be a numeric array whose first entry is a string containing an SQL expression, and whose further 81 | * entries are values to be bound for each question mark placeholder the SQL expression contains. 82 | * 83 | * Example for a simple scalar value mapping: 84 | * [ 85 | * ['?', $value] 86 | * ] 87 | * 88 | * Example for a geometry object, mapping 2 values to a single field: 89 | * [ 90 | * ['ST_GeomFromText(?, ?)', $wkt, $srid] 91 | * ] 92 | * 93 | * Example for a complex property mapping many values to 4 fields: 94 | * [ 95 | * ['NULL'], 96 | * ['?', $value1], 97 | * ['?', $value2], 98 | * ['ST_GeomFromText(?, ?)', $wkt, $srid] 99 | * ] 100 | * 101 | * @psalm-return list> 102 | * 103 | * @param mixed $propValue The property value. 104 | */ 105 | public function convertPropToFields(mixed $propValue) : array; 106 | } 107 | -------------------------------------------------------------------------------- /src/PropertyMapping/BoolMapping.php: -------------------------------------------------------------------------------- 1 | fieldName = $fieldName; 21 | $this->isNullable = $isNullable; 22 | } 23 | 24 | public function isNullable() : bool 25 | { 26 | return $this->isNullable; 27 | } 28 | 29 | public function getFieldNames() : array 30 | { 31 | return [$this->fieldName]; 32 | } 33 | 34 | public function getInputValuesCount() : int 35 | { 36 | return 1; 37 | } 38 | 39 | public function getFieldToInputValuesSQL(array $fieldNames) : array 40 | { 41 | return $fieldNames; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/PropertyMapping/EmbeddableMapping.php: -------------------------------------------------------------------------------- 1 | classMetadata = $classMetadata; 35 | $this->fieldNamePrefix = $fieldNamePrefix; 36 | $this->isNullable = $isNullable; 37 | } 38 | 39 | public function getType() : string|null 40 | { 41 | return $this->classMetadata->className; 42 | } 43 | 44 | public function isNullable() : bool 45 | { 46 | return $this->isNullable; 47 | } 48 | 49 | /** 50 | * @todo precompute for better performance 51 | */ 52 | public function getFieldNames() : array 53 | { 54 | $names = []; 55 | 56 | foreach ($this->classMetadata->properties as $prop) { 57 | foreach ($this->classMetadata->propertyMappings[$prop]->getFieldNames() as $name) { 58 | $names[] = $this->fieldNamePrefix . $name; 59 | } 60 | } 61 | 62 | return $names; 63 | } 64 | 65 | /** 66 | * @todo precompute for better performance 67 | */ 68 | public function getInputValuesCount() : int 69 | { 70 | $count = 0; 71 | 72 | foreach ($this->classMetadata->properties as $prop) { 73 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 74 | $count += $propertyMapping->getInputValuesCount(); 75 | } 76 | 77 | return $count; 78 | } 79 | 80 | /** 81 | * @todo precompute for better performance 82 | */ 83 | public function getFieldToInputValuesSQL(array $fieldNames) : array 84 | { 85 | $wrappedFields = []; 86 | $currentIndex = 0; 87 | 88 | foreach ($this->classMetadata->properties as $prop) { 89 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 90 | $readFieldCount = $propertyMapping->getInputValuesCount(); 91 | 92 | $currentFieldNames = array_slice($fieldNames, $currentIndex, $readFieldCount); 93 | $currentIndex += $readFieldCount; 94 | 95 | foreach ($propertyMapping->getFieldToInputValuesSQL($currentFieldNames) as $wrappedField) { 96 | $wrappedFields[] = $wrappedField; 97 | } 98 | } 99 | 100 | return $wrappedFields; 101 | } 102 | 103 | public function convertInputValuesToProp(Gateway $gateway, array $values) : mixed 104 | { 105 | $currentIndex = 0; 106 | 107 | $propValues = []; 108 | 109 | foreach ($this->classMetadata->properties as $prop) { 110 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 111 | $readFieldCount = $propertyMapping->getInputValuesCount(); 112 | 113 | $currentInputValues = array_slice($values, $currentIndex, $readFieldCount); 114 | $currentIndex += $readFieldCount; 115 | 116 | $propValues[$prop] = $propertyMapping->convertInputValuesToProp($gateway, $currentInputValues); 117 | } 118 | 119 | // @todo keep an ObjectFactory cache. 120 | $objectFactory = new ObjectFactory(); 121 | 122 | return $objectFactory->instantiate($this->classMetadata, $propValues); 123 | } 124 | 125 | public function convertPropToFields(mixed $propValue) : array 126 | { 127 | $result = []; 128 | 129 | /** @var object|null $entity */ 130 | $entity = $propValue; 131 | 132 | $r = null; 133 | 134 | foreach ($this->classMetadata->properties as $prop) { 135 | if ($entity === null) { 136 | $idPropValue = null; 137 | } else { 138 | if ($r === null) { 139 | $r = new ReflectionObject($entity); 140 | } 141 | 142 | $p = $r->getProperty($prop); 143 | $idPropValue = $p->getValue($entity); 144 | } 145 | 146 | foreach ($this->classMetadata->propertyMappings[$prop]->convertPropToFields($idPropValue) as $expressionAndValues) { 147 | $result[] = $expressionAndValues; 148 | } 149 | } 150 | 151 | return $result; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/PropertyMapping/EntityMapping.php: -------------------------------------------------------------------------------- 1 | classMetadata = $classMetadata; 33 | $this->fieldNamePrefix = $fieldNamePrefix; 34 | $this->isNullable = $isNullable; 35 | } 36 | 37 | public function getType() : string|null 38 | { 39 | return $this->classMetadata->className; 40 | } 41 | 42 | public function isNullable() : bool 43 | { 44 | return $this->isNullable; 45 | } 46 | 47 | /** 48 | * @todo precompute for better performance 49 | */ 50 | public function getFieldNames() : array 51 | { 52 | $names = []; 53 | 54 | if ($this->classMetadata->discriminatorColumn !== null) { 55 | $names[] = $this->fieldNamePrefix . $this->classMetadata->discriminatorColumn; 56 | } 57 | 58 | foreach ($this->classMetadata->idProperties as $prop) { 59 | foreach ($this->classMetadata->propertyMappings[$prop]->getFieldNames() as $name) { 60 | $names[] = $this->fieldNamePrefix . $name; 61 | } 62 | } 63 | 64 | return $names; 65 | } 66 | 67 | /** 68 | * @todo precompute for better performance 69 | */ 70 | public function getInputValuesCount() : int 71 | { 72 | $count = 0; 73 | 74 | if ($this->classMetadata->discriminatorColumn !== null) { 75 | $count++; 76 | } 77 | 78 | foreach ($this->classMetadata->idProperties as $prop) { 79 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 80 | $count += $propertyMapping->getInputValuesCount(); 81 | } 82 | 83 | return $count; 84 | } 85 | 86 | /** 87 | * @todo precompute for better performance 88 | */ 89 | public function getFieldToInputValuesSQL(array $fieldNames) : array 90 | { 91 | $wrappedFields = []; 92 | $currentIndex = 0; 93 | 94 | if ($this->classMetadata->discriminatorColumn !== null) { 95 | $wrappedFields[] = $fieldNames[$currentIndex++]; 96 | } 97 | 98 | foreach ($this->classMetadata->idProperties as $prop) { 99 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 100 | $readFieldCount = $propertyMapping->getInputValuesCount(); 101 | 102 | $currentFieldNames = array_slice($fieldNames, $currentIndex, $readFieldCount); 103 | $currentIndex += $readFieldCount; 104 | 105 | foreach ($propertyMapping->getFieldToInputValuesSQL($currentFieldNames) as $wrappedField) { 106 | $wrappedFields[] = $wrappedField; 107 | } 108 | } 109 | 110 | return $wrappedFields; 111 | } 112 | 113 | public function convertInputValuesToProp(Gateway $gateway, array $values) : mixed 114 | { 115 | $currentIndex = 0; 116 | 117 | if ($this->classMetadata->discriminatorColumn !== null) { 118 | /** @var int|string|null $discriminatorValue */ 119 | $discriminatorValue = $values[$currentIndex++]; 120 | 121 | if ($discriminatorValue === null) { 122 | return null; 123 | } 124 | 125 | $className = $this->classMetadata->discriminatorMap[$discriminatorValue]; 126 | } else { 127 | $className = $this->classMetadata->className; 128 | } 129 | 130 | $id = []; 131 | 132 | foreach ($this->classMetadata->idProperties as $prop) { 133 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 134 | $readFieldCount = $propertyMapping->getInputValuesCount(); 135 | 136 | $currentInputValues = array_slice($values, $currentIndex, $readFieldCount); 137 | $currentIndex += $readFieldCount; 138 | 139 | $value = $propertyMapping->convertInputValuesToProp($gateway, $currentInputValues); 140 | 141 | if ($value === null) { 142 | return null; 143 | } 144 | 145 | $id[$prop] = $value; 146 | } 147 | 148 | return $gateway->getReference($className, $id); 149 | } 150 | 151 | /** 152 | * @todo use Gateway::getIdentity() instead; currently does not check that the object has an identity 153 | */ 154 | public function convertPropToFields(mixed $propValue) : array 155 | { 156 | $result = []; 157 | 158 | /** @var object|null $entity */ 159 | $entity = $propValue; 160 | 161 | if ($this->classMetadata->discriminatorColumn !== null) { 162 | if ($entity === null) { 163 | $result[] = ['NULL']; 164 | } else { 165 | $class = get_class($entity); 166 | $discriminatorValue = $this->classMetadata->inverseDiscriminatorMap[$class]; 167 | $result[] = ['?', $discriminatorValue]; 168 | } 169 | } 170 | 171 | $idProperties = $this->classMetadata->idProperties; 172 | 173 | $identity = []; 174 | 175 | if ($entity !== null) { 176 | /** @psalm-suppress PossiblyNullFunctionCall bindTo() should never return null here */ 177 | (function() use ($idProperties, & $identity) { 178 | foreach ($idProperties as $prop) { 179 | $identity[$prop] = $this->{$prop}; 180 | } 181 | })->bindTo($entity, $entity)(); 182 | } else { 183 | foreach ($idProperties as $prop) { 184 | $identity[$prop] = null; 185 | } 186 | } 187 | 188 | foreach ($idProperties as $prop) { 189 | $propertyMapping = $this->classMetadata->propertyMappings[$prop]; 190 | 191 | foreach ($propertyMapping->convertPropToFields($identity[$prop]) as $expressionAndValues) { 192 | $result[] = $expressionAndValues; 193 | } 194 | } 195 | 196 | return $result; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/PropertyMapping/IntMapping.php: -------------------------------------------------------------------------------- 1 | fieldName = $fieldName; 27 | $this->isNullable = $isNullable; 28 | $this->objectAsArray = $objectAsArray; 29 | } 30 | 31 | public function isNullable() : bool 32 | { 33 | return $this->isNullable; 34 | } 35 | 36 | public function getFieldNames() : array 37 | { 38 | return [$this->fieldName]; 39 | } 40 | 41 | public function getInputValuesCount() : int 42 | { 43 | return 1; 44 | } 45 | 46 | public function getFieldToInputValuesSQL(array $fieldNames) : array 47 | { 48 | return $fieldNames; 49 | } 50 | 51 | public function getType() : string|null 52 | { 53 | return null; 54 | } 55 | 56 | public function convertInputValuesToProp(Gateway $gateway, array $values) : mixed 57 | { 58 | /** @var array{string|null} $values */ 59 | 60 | if ($values[0] === null) { 61 | return null; 62 | } 63 | 64 | return json_decode($values[0], $this->objectAsArray); 65 | } 66 | 67 | public function convertPropToFields(mixed $propValue) : array 68 | { 69 | if ($propValue === null) { 70 | return [ 71 | ['NULL'] 72 | ]; 73 | } 74 | 75 | return [ 76 | ['?', json_encode($propValue)] 77 | ]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/PropertyMapping/StringMapping.php: -------------------------------------------------------------------------------- 1 | |null 18 | * 19 | * @var string[]|null 20 | */ 21 | private array|null $nonIdProps = null; 22 | 23 | /** 24 | * @param string $namespace The namespace of the proxy class. 25 | */ 26 | public function setProxyNamespace(string $namespace) : void 27 | { 28 | $this->proxyNamespace = $namespace; 29 | } 30 | 31 | /** 32 | * @psalm-param class-string $className 33 | * 34 | * @param string $className The FQCN of the entity. 35 | */ 36 | public function setEntityClassName(string $className) : void 37 | { 38 | $this->entityClassName = $className; 39 | } 40 | 41 | /** 42 | * @psalm-param list $props 43 | * 44 | * @param string[] $props The list of non-identity properties. 45 | */ 46 | public function setNonIdProps(array $props) : void 47 | { 48 | $this->nonIdProps = $props; 49 | } 50 | 51 | /** 52 | * Builds and returns the proxy source code. 53 | * 54 | * @throws \RuntimeException If data are missing. 55 | * @throws \ReflectionException If a class does not exist. 56 | */ 57 | public function build() : string 58 | { 59 | if ($this->proxyNamespace === null) { 60 | throw new \RuntimeException('Missing proxy namespace.'); 61 | } 62 | 63 | if ($this->entityClassName === null) { 64 | throw new \RuntimeException('Missing entity class name.'); 65 | } 66 | 67 | if ($this->nonIdProps === null) { 68 | throw new \RuntimeException('Missing non-id props.'); 69 | } 70 | 71 | $imports = [ 72 | $this->entityClassName 73 | ]; 74 | 75 | $entityClassShortName = (new \ReflectionClass($this->entityClassName))->getShortName(); 76 | 77 | $code = file_get_contents(__DIR__ . '/ProxyTemplate.php'); 78 | 79 | $code = str_replace('PROXY_NAMESPACE', $this->proxyNamespace, $code); 80 | $code = str_replace('CLASS_NAME', $entityClassShortName, $code); 81 | 82 | if ($this->nonIdProps) { 83 | $unsets = "\n"; 84 | 85 | $unsets .= implode(",\n", array_map(static function(string $prop) : string { 86 | return str_repeat(' ', 12) . '$this->' . $prop; 87 | }, $this->nonIdProps)); 88 | 89 | $unsets .= "\n" . str_repeat(' ' , 8); 90 | 91 | $code = str_replace('$UNSET_NON_ID_PROPS', $unsets, $code); 92 | } else { 93 | $code = str_replace('unset($UNSET_NON_ID_PROPS);', '', $code); 94 | } 95 | 96 | $nonIdProps = array_map(static function(string $prop) : string { 97 | return var_export($prop, true); 98 | }, $this->nonIdProps); 99 | 100 | $code = str_replace('NON_ID_PROPS', implode(', ', $nonIdProps), $code); 101 | 102 | // Imports 103 | 104 | $importString = ''; 105 | 106 | $imports = array_values(array_unique($imports)); 107 | 108 | foreach ($imports as $key => $import) { 109 | if ($key !== 0) { 110 | $importString .= ' '; 111 | } 112 | 113 | $importString .= $import; 114 | 115 | if ($key !== count($imports) - 1) { 116 | $importString .= ",\n"; 117 | } 118 | } 119 | 120 | $code = str_replace('IMPORTS', $importString, $code); 121 | 122 | return $code; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/ProxyTemplate.php: -------------------------------------------------------------------------------- 1 | $identity 33 | * @psalm-param list $scalarIdentity 34 | * 35 | * @param Gateway $gateway The gateway. 36 | * @param array $identity The identity, as a map of property name to value. 37 | * @param array $scalarIdentity The identity, as a list of scalar values. 38 | */ 39 | public function __construct(Gateway $gateway, array $identity, array $scalarIdentity) 40 | { 41 | $this->__gateway = $gateway; 42 | $this->__identity = $identity; 43 | $this->__scalarIdentity = $scalarIdentity; 44 | 45 | foreach ($identity as $prop => $value) { 46 | $this->{$prop} = $value; 47 | } 48 | 49 | unset($UNSET_NON_ID_PROPS); 50 | } 51 | 52 | public function __get(string $name) : mixed 53 | { 54 | if (! $this->__isInitialized) { 55 | $loadProps = []; 56 | 57 | foreach (self::__NON_ID_PROPERTIES as $prop) { 58 | if (! isset($this->{$prop})) { // exclude initialized properties 59 | $loadProps[] = $prop; 60 | } 61 | } 62 | 63 | if ($loadProps) { 64 | $propValues = $this->__gateway->loadProps(CLASS_NAME::class, $this->__identity, $loadProps); 65 | 66 | if ($propValues === null) { 67 | throw EntityNotFoundException::entityNotFound(CLASS_NAME::class, $this->__scalarIdentity); 68 | } 69 | 70 | foreach ($propValues as $prop => $value) { 71 | $this->{$prop} = $value; 72 | } 73 | } 74 | 75 | $this->__isInitialized = true; 76 | } 77 | 78 | return $this->{$name}; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | |null 18 | * 19 | * @var string[]|null 20 | */ 21 | private array|null $properties = null; 22 | 23 | /** 24 | * @psalm-var list 25 | * 26 | * @var QueryPredicate[] 27 | */ 28 | private array $predicates = []; 29 | 30 | /** 31 | * @psalm-var list 32 | * 33 | * @var QueryOrderBy[] 34 | */ 35 | private array $orderBy = []; 36 | 37 | private int|null $limit = null; 38 | 39 | private int|null $offset = null; 40 | 41 | /** 42 | * @psalm-param class-string $className 43 | */ 44 | public function __construct(string $className) 45 | { 46 | $this->className = $className; 47 | } 48 | 49 | public function setProperties(string ...$properties) : Query 50 | { 51 | $this->properties = array_values($properties); 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * @throws \InvalidArgumentException If the operator is invalid. 58 | */ 59 | public function addPredicate(string $property, string $operator, mixed $value) : Query 60 | { 61 | $this->predicates[] = new QueryPredicate($property, $operator, $value); 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @param string $property The property to order by. 68 | * @param string $direction The order direction, 'ASC' or 'DESC'. 69 | * 70 | * @throws \InvalidArgumentException If the order direction is invalid. 71 | */ 72 | public function addOrderBy(string $property, string $direction = 'ASC') : Query 73 | { 74 | $this->orderBy[] = new QueryOrderBy($property, $direction); 75 | 76 | return $this; 77 | } 78 | 79 | public function setLimit(int $limit, int $offset = 0) : Query 80 | { 81 | $this->limit = $limit; 82 | $this->offset = $offset; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @psalm-return class-string 89 | */ 90 | public function getClassName() : string 91 | { 92 | return $this->className; 93 | } 94 | 95 | /** 96 | * @psalm-return list|null 97 | * 98 | * @return string[]|null 99 | */ 100 | public function getProperties() : array|null 101 | { 102 | return $this->properties; 103 | } 104 | 105 | /** 106 | * @psalm-return list 107 | * 108 | * @return QueryPredicate[] 109 | */ 110 | public function getPredicates() : array 111 | { 112 | return $this->predicates; 113 | } 114 | 115 | /** 116 | * @psalm-return list 117 | * 118 | * @return QueryOrderBy[] 119 | */ 120 | public function getOrderBy() : array 121 | { 122 | return $this->orderBy; 123 | } 124 | 125 | public function getLimit() : int|null 126 | { 127 | return $this->limit; 128 | } 129 | 130 | public function getOffset() : int|null 131 | { 132 | return $this->offset; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/QueryOrderBy.php: -------------------------------------------------------------------------------- 1 | property = $property; 31 | $this->direction = $direction; 32 | } 33 | 34 | public function getProperty() : string 35 | { 36 | return $this->property; 37 | } 38 | 39 | /** 40 | * @psalm-return 'ASC'|'DESC' 41 | */ 42 | public function getDirection() : string 43 | { 44 | return $this->direction; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/QueryPredicate.php: -------------------------------------------------------------------------------- 1 | ". 18 | * @param mixed $value The value to compare against. 19 | * 20 | * @throws \InvalidArgumentException If the operator is invalid. 21 | */ 22 | public function __construct(string $property, string $operator, mixed $value) 23 | { 24 | if (! in_array($operator, ['=', '!=', '>', '<', '>=', '<='])) { 25 | throw new \InvalidArgumentException(sprintf('Unknown operator "%s".', $operator)); 26 | } 27 | 28 | $this->property = $property; 29 | $this->operator = $operator; 30 | $this->value = $value; 31 | } 32 | 33 | public function getProperty() : string 34 | { 35 | return $this->property; 36 | } 37 | 38 | public function getOperator() : string 39 | { 40 | return $this->operator; 41 | } 42 | 43 | public function getValue() : mixed 44 | { 45 | return $this->value; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/RepositoryBuilder.php: -------------------------------------------------------------------------------- 1 | |null 18 | */ 19 | private array|null $identityProps = null; 20 | 21 | /** 22 | * @param string $namespace The namespace of the repository. 23 | */ 24 | public function setRepositoryNamespace(string $namespace) : void 25 | { 26 | $this->repositoryNamespace = $namespace; 27 | } 28 | 29 | /** 30 | * @psalm-param class-string $className 31 | * 32 | * @param string $className The FQCN of the entity. 33 | */ 34 | public function setEntityClassName(string $className) : void 35 | { 36 | $this->entityClassName = $className; 37 | } 38 | 39 | /** 40 | * @psalm-param array $props 41 | * 42 | * @param array $props An associative array of property name to type. 43 | */ 44 | public function setIdentityProps(array $props) : void 45 | { 46 | $this->identityProps = $props; 47 | } 48 | 49 | /** 50 | * Builds and returns the repository source code. 51 | * 52 | * @throws \RuntimeException If data are missing. 53 | * @throws \ReflectionException If a class does not exist. 54 | */ 55 | public function build() : string 56 | { 57 | if ($this->repositoryNamespace === null) { 58 | throw new \RuntimeException('Missing repository namespace.'); 59 | } 60 | 61 | if ($this->entityClassName === null) { 62 | throw new \RuntimeException('Missing entity class name.'); 63 | } 64 | 65 | if ($this->identityProps === null) { 66 | throw new \RuntimeException('Missing identity props.'); 67 | } 68 | 69 | $imports = [ 70 | $this->entityClassName 71 | ]; 72 | 73 | $entityClassShortName = (new \ReflectionClass($this->entityClassName))->getShortName(); 74 | 75 | $code = file_get_contents(__DIR__ . '/RepositoryTemplate.php'); 76 | 77 | $code = str_replace('REPO_NAMESPACE', $this->repositoryNamespace, $code); 78 | $code = str_replace('CLASS_NAME', $entityClassShortName, $code); 79 | $code = str_replace('ENTITY_PROP_NAME', $this->getParamNameForClassName($entityClassShortName), $code); 80 | 81 | // Identity props & array 82 | 83 | $builtInTypes = [ 84 | 'bool', 85 | 'int', 86 | 'float', 87 | 'string', 88 | 'array', 89 | 'object', 90 | 'callable', 91 | 'iterable' 92 | ]; 93 | 94 | $identityProps = []; 95 | $identityArray = []; 96 | 97 | foreach ($this->identityProps as $prop => $type) { 98 | $typeLower = strtolower($type); 99 | 100 | if (in_array($typeLower, $builtInTypes, true)) { 101 | $type = $typeLower; 102 | } else { 103 | /** @psalm-var class-string $type */ 104 | $imports[] = $type; 105 | $type = (new \ReflectionClass($type))->getShortName(); 106 | } 107 | 108 | $identityProps[] = $type . ' $' . $prop; 109 | $identityArray[] = var_export($prop, true) . ' => $' . $prop; 110 | } 111 | 112 | $code = str_replace('$IDENTITY_PROPS', implode(', ', $identityProps), $code); 113 | $code = str_replace('IDENTITY_ARRAY', '[' . implode(', ', $identityArray) . ']', $code); 114 | 115 | // Imports 116 | 117 | $importString = ''; 118 | 119 | $imports = array_values(array_unique($imports)); 120 | 121 | foreach ($imports as $key => $import) { 122 | if ($key !== 0) { 123 | $importString .= ' '; 124 | } 125 | 126 | $importString .= $import; 127 | 128 | if ($key !== count($imports) - 1) { 129 | $importString .= ",\n"; 130 | } 131 | } 132 | 133 | $code = str_replace('IMPORTS', $importString, $code); 134 | 135 | return $code; 136 | } 137 | 138 | /** 139 | * Returns a suitable parameter name for a class name. 140 | * 141 | * Examples: 'User' => 'user', 'ABBREntity' => 'abbrEntity'. 142 | */ 143 | private function getParamNameForClassName(string $className) : string 144 | { 145 | $length = strlen($className); 146 | 147 | $upperLength = 0; 148 | 149 | for ($i = 0; $i < $length; $i++) { 150 | if ($this->isUppercase($className[$i])) { 151 | $upperLength++; 152 | } else { 153 | break; 154 | } 155 | } 156 | 157 | if ($upperLength === 0) { 158 | return $className; 159 | } 160 | 161 | if ($upperLength > 1) { 162 | $upperLength--; 163 | } 164 | 165 | return strtolower(substr($className, 0, $upperLength)) . substr($className, $upperLength); 166 | } 167 | 168 | /** 169 | * Checks if an ASCII letter is uppercase. 170 | */ 171 | private function isUppercase(string $letter) : bool 172 | { 173 | $ord = ord($letter); 174 | 175 | return ($ord >= 65) && ($ord <= 90); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/RepositoryTemplate.php: -------------------------------------------------------------------------------- 1 | gateway = $gateway; 26 | } 27 | 28 | public function load($IDENTITY_PROPS, int $options = 0, string ...$props) : CLASS_NAME|null 29 | { 30 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 31 | return $this->gateway->load(CLASS_NAME::class, IDENTITY_ARRAY, $options, ...$props); 32 | } 33 | 34 | public function getReference($IDENTITY_PROPS) : CLASS_NAME 35 | { 36 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 37 | return $this->gateway->getReference(CLASS_NAME::class, IDENTITY_ARRAY); 38 | } 39 | 40 | public function exists(CLASS_NAME $ENTITY_PROP_NAME) : bool 41 | { 42 | return $this->gateway->exists($ENTITY_PROP_NAME); 43 | } 44 | 45 | public function existsIdentity($IDENTITY_PROPS) : bool 46 | { 47 | return $this->gateway->existsIdentity(CLASS_NAME::class, IDENTITY_ARRAY); 48 | } 49 | 50 | public function add(CLASS_NAME $ENTITY_PROP_NAME) : void 51 | { 52 | $this->gateway->add($ENTITY_PROP_NAME); 53 | } 54 | 55 | public function update(CLASS_NAME $ENTITY_PROP_NAME) : void 56 | { 57 | $this->gateway->update($ENTITY_PROP_NAME); 58 | } 59 | 60 | public function remove(CLASS_NAME $ENTITY_PROP_NAME) : void 61 | { 62 | $this->gateway->remove($ENTITY_PROP_NAME); 63 | } 64 | 65 | public function removeIdentity($IDENTITY_PROPS) : void 66 | { 67 | $this->gateway->removeIdentity(CLASS_NAME::class, IDENTITY_ARRAY); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SelectQueryBuilder.php: -------------------------------------------------------------------------------- 1 | 18 | * 19 | * @var string[] 20 | */ 21 | private array $selectFields; 22 | 23 | /** 24 | * The table name. 25 | */ 26 | private string $tableName; 27 | 28 | /** 29 | * An optional table alias. 30 | */ 31 | private string|null $tableAlias; 32 | 33 | /** 34 | * @psalm-var list 35 | * 36 | * @var string[] 37 | */ 38 | private array $joins = []; 39 | 40 | /** 41 | * @psalm-var list 42 | * 43 | * @var string[] 44 | */ 45 | private array $whereConditions = []; 46 | 47 | /** 48 | * @psalm-var list 49 | * 50 | * @var string[] 51 | */ 52 | private array $orderBy = []; 53 | 54 | private string $limit = ''; 55 | 56 | private int $options = 0; 57 | 58 | /** 59 | * @psalm-param list $selectFields 60 | * 61 | * @param string[] $selectFields The fields or expressions to SELECT. 62 | * @param string $tableName The table name. 63 | * @param string|null $tableAlias An optional table alias. 64 | */ 65 | public function __construct(array $selectFields, string $tableName, string|null $tableAlias = null) 66 | { 67 | $this->selectFields = $selectFields; 68 | $this->tableName = $tableName; 69 | $this->tableAlias = $tableAlias; 70 | } 71 | 72 | /** 73 | * @psalm-param list $joinConditions 74 | * 75 | * @param string $joinType The JOIN type, such as INNER or LEFT. 76 | * @param string $tableName The table name. 77 | * @param string $tableAlias The table alias. 78 | * @param string[] $joinConditions The list of A=B join conditions. 79 | */ 80 | public function addJoin(string $joinType, string $tableName, string $tableAlias, array $joinConditions) : void 81 | { 82 | $this->joins[] = ' ' . $joinType . ' JOIN ' . $tableName . 83 | ' AS ' . $tableAlias . 84 | ' ON ' . implode(' AND ', $joinConditions); 85 | } 86 | 87 | /** 88 | * Adds WHERE conditions to be AND'ed to the current conditions. 89 | * 90 | * The conditions will be AND'ed or OR'ed together, according to the given operator, and AND'ed as a whole to the 91 | * existing conditions. 92 | * 93 | * @psalm-param list $whereConditions 94 | * @psalm-param 'AND'|'OR' $operator 95 | * 96 | * @param string[] $whereConditions The WHERE conditions. 97 | * @param string $operator The operator, 'AND' or 'OR'. 98 | */ 99 | public function addWhereConditions(array $whereConditions, string $operator = 'AND') : void 100 | { 101 | $parentheses = ($operator === 'OR' && count($whereConditions) > 1); 102 | 103 | $whereConditions = implode(' ' . $operator . ' ', $whereConditions); 104 | 105 | if ($parentheses) { 106 | $whereConditions = '(' . $whereConditions . ')'; 107 | } 108 | 109 | $this->whereConditions[] = $whereConditions; 110 | } 111 | 112 | /** 113 | * @psalm-param 'ASC'|'DESC' $direction 114 | * 115 | * @param string $expression The expression to order by. 116 | * @param string $direction The order direction, 'ASC' or 'DESC'. 117 | */ 118 | public function addOrderBy(string $expression, string $direction = 'ASC') : void 119 | { 120 | $this->orderBy[] = $expression . ' ' . $direction; 121 | } 122 | 123 | public function setLimit(int $limit, int $offset = 0) : void 124 | { 125 | $this->limit = ' LIMIT ' . $limit; 126 | 127 | if ($offset !== 0) { 128 | $this->limit .= ' OFFSET ' . $offset; 129 | } 130 | } 131 | 132 | /** 133 | * @param int $options A bitmask of options. 134 | */ 135 | public function setOptions(int $options) : void 136 | { 137 | $this->options = $options; 138 | } 139 | 140 | public function build() : string 141 | { 142 | $query = 'SELECT ' . implode(', ', $this->selectFields) . ' FROM ' . $this->tableName; 143 | 144 | if ($this->tableAlias !== null) { 145 | $query .= ' AS ' . $this->tableAlias; 146 | } 147 | 148 | foreach ($this->joins as $join) { 149 | $query .= $join; 150 | } 151 | 152 | if ($this->whereConditions) { 153 | $query .= ' WHERE ' . implode(' AND ', $this->whereConditions); 154 | } 155 | 156 | if ($this->orderBy) { 157 | $query .= ' ORDER BY ' . implode(', ', $this->orderBy); 158 | } 159 | 160 | $query .= $this->limit; 161 | 162 | // @todo lock mode syntax is MySQL / PostgreSQL only 163 | 164 | if ($this->options & Options::LOCK_READ) { 165 | $query .= ' FOR SHARE'; 166 | } elseif ($this->options & Options::LOCK_WRITE) { 167 | $query .= ' FOR UPDATE'; 168 | } 169 | 170 | if ($this->options & Options::SKIP_LOCKED) { 171 | $query .= ' SKIP LOCKED'; 172 | } 173 | 174 | if ($this->options & Options::NOWAIT) { 175 | $query .= ' NOWAIT'; 176 | } 177 | 178 | return $query; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/TableAliasGenerator.php: -------------------------------------------------------------------------------- 1 | number++; 14 | 15 | if ($this->number <= 25) { 16 | // a to y 17 | return chr(96 + $this->number); 18 | } 19 | 20 | // z1, z2, etc. 21 | return 'z' . ($this->number - 25); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests-config.php: -------------------------------------------------------------------------------- 1 | setRepositoryNamespace('Brick\ORM\Tests\Generated\Repository'); 15 | $config->setRepositoryDir(__DIR__ . '/tests/Generated/Repository'); 16 | 17 | $config->setProxyNamespace('Brick\ORM\Tests\Generated\Proxy'); 18 | $config->setProxyDir(__DIR__ . '/tests/Generated/Proxy'); 19 | 20 | $config->setBaseEntityNamespace('Brick\ORM\Tests\Resources\Models'); 21 | $config->setClassMetadataFile(__DIR__ . '/tests/Generated/ClassMetadata.php'); 22 | 23 | $config->addEntity(Models\User::class) 24 | ->setIdentityProperties('id') 25 | ->setAutoIncrement(); 26 | 27 | $config->addEntity(Models\Follow::class) 28 | ->setIdentityProperties('follower', 'followee'); 29 | 30 | $config->addEntity(Models\Country::class) 31 | ->setIdentityProperties('code'); 32 | 33 | $config->addEntity(Brick\ORM\Tests\Resources\Models\Event::class) 34 | ->setIdentityProperties('id') 35 | ->setAutoIncrement() 36 | ->setInheritanceMapping('type', [ 37 | 'CreateCountry' => Models\Event\CountryEvent\CreateCountryEvent::class, 38 | 'EditCountryName' => Models\Event\CountryEvent\EditCountryNameEvent::class, 39 | 'CreateUser' => Models\Event\UserEvent\CreateUserEvent::class, 40 | 'EditUserBillingAddress' => Models\Event\UserEvent\EditUserBillingAddressEvent::class, 41 | 'EditUserDeliveryAddress' => Models\Event\UserEvent\EditUserDeliveryAddressEvent::class, 42 | 'EditUserName' => Models\Event\UserEvent\EditUserNameEvent::class, 43 | 'FollowUser' => Models\Event\FollowUserEvent::class 44 | ]); 45 | 46 | $config->addEmbeddable(Models\Address::class); 47 | $config->addEmbeddable(Models\GeoAddress::class); 48 | 49 | $config->addCustomMapping(Objects\Geometry::class, Mappings\GeometryMapping::class); 50 | 51 | // Set transient properties 52 | $config->setTransientProperties(Models\User::class, 'transient'); 53 | 54 | // Override field names / prefixes 55 | $config->setFieldName(Models\Address::class, 'postcode', 'zipcode'); 56 | $config->setFieldNamePrefix(Models\User::class, 'billingAddress', ''); 57 | 58 | $config->setCustomPropertyMapping(Models\User::class, 'data', new JsonMapping('data', false, true)); 59 | 60 | return $config; 61 | })(); 62 | -------------------------------------------------------------------------------- /tests/AbstractTestCase.php: -------------------------------------------------------------------------------- 1 | exec('DROP DATABASE IF EXISTS orm_tests'); 60 | $connection->exec('CREATE DATABASE orm_tests'); 61 | $connection->exec('USE orm_tests'); 62 | 63 | $classMetadata = require __DIR__ . '/Generated/ClassMetadata.php'; 64 | 65 | self::$connection = $connection; 66 | self::$gateway = new Gateway($connection, $classMetadata, null, static::useProxies()); 67 | 68 | self::$countryRepository = new CountryRepository(self::$gateway); 69 | self::$userRepository = new UserRepository(self::$gateway); 70 | self::$eventRepository = new EventRepository(self::$gateway); 71 | 72 | $connection->exec('DROP TABLE IF EXISTS Country'); 73 | $connection->exec(' 74 | CREATE TABLE Country ( 75 | code CHAR(2) NOT NULL PRIMARY KEY, 76 | name VARCHAR(50) NOT NULL 77 | ) 78 | '); 79 | 80 | $connection->exec('DROP TABLE IF EXISTS User'); 81 | $connection->exec(' 82 | CREATE TABLE User ( 83 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 84 | name VARCHAR(50) NOT NULL, 85 | street VARCHAR(50) NULL, 86 | city VARCHAR(50) NULL, 87 | zipcode VARCHAR(50) NULL, 88 | country_code CHAR(2) NULL, 89 | isPoBox TINYINT(1) NULL, 90 | deliveryAddress_address_street VARCHAR(50) NULL, 91 | deliveryAddress_address_city VARCHAR(50) NULL, 92 | deliveryAddress_address_zipcode VARCHAR(50) NULL, 93 | deliveryAddress_address_country_code CHAR(2) NULL, 94 | deliveryAddress_address_isPoBox TINYINT(1) NULL, 95 | deliveryAddress_location GEOMETRY NULL, 96 | lastEvent_type VARCHAR(30) NULL, 97 | lastEvent_id INT(10) UNSIGNED NULL, 98 | data JSON NOT NULL 99 | ) 100 | '); 101 | 102 | $connection->exec('DROP TABLE IF EXISTS Event'); 103 | $connection->exec(' 104 | CREATE TABLE Event ( 105 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 106 | type VARCHAR(30) NOT NULL, 107 | time INT(10) UNSIGNED NOT NULL, 108 | user_id INT(10) UNSIGNED NULL, 109 | follower_id INT(10) UNSIGNED NULL, 110 | followee_id INT(10) UNSIGNED NULL, 111 | country_code CHAR(2) NULL, 112 | isFollow TINYINT(1) NULL, 113 | newName VARCHAR(50) NULL, 114 | newAddress_street VARCHAR(50) NULL, 115 | newAddress_city VARCHAR(50) NULL, 116 | newAddress_zipcode VARCHAR(50) NULL, 117 | newAddress_country_code CHAR(2) NULL, 118 | newAddress_isPoBox TINYINT(1) NULL, 119 | newAddress_address_street VARCHAR(50) NULL, 120 | newAddress_address_city VARCHAR(50) NULL, 121 | newAddress_address_zipcode VARCHAR(50) NULL, 122 | newAddress_address_country_code CHAR(2) NULL, 123 | newAddress_address_isPoBox TINYINT(1) NULL, 124 | newAddress_location GEOMETRY NULL 125 | ) 126 | '); 127 | } 128 | 129 | /** 130 | * {@inheritdoc} 131 | */ 132 | protected function setUp() : void 133 | { 134 | self::$logger->reset(); 135 | } 136 | 137 | /** 138 | * @param int $count 139 | * 140 | * @return void 141 | */ 142 | protected function assertDebugStatementCount(int $count) : void 143 | { 144 | self::assertSame($count, self::$logger->count()); 145 | } 146 | 147 | /** 148 | * @param int $index 149 | * @param string $statement 150 | * @param mixed ...$parameters 151 | * 152 | * @return void 153 | */ 154 | protected function assertDebugStatement(int $index, string $statement, ...$parameters) : void 155 | { 156 | $debugStatement = self::$logger->getDebugStatement($index); 157 | 158 | self::assertSame($statement, $debugStatement->getStatement()); 159 | self::assertSame($parameters, $debugStatement->getParameters()); 160 | self::assertIsFloat($debugStatement->getTime()); 161 | self::assertGreaterThan(0.0, $debugStatement->getTime()); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/GatewayNativeQueryTest.php: -------------------------------------------------------------------------------- 1 | exec(<<lastInsertId(); 24 | 25 | for ($i = 1; $i <= 3; $i++) { 26 | self::$connection->exec(<<nativeQuery(UserDTO::class, <<assertCount(1, $users); 48 | 49 | $user = $users[0]; 50 | 51 | $this->assertSame($userId, $user->id); 52 | $this->assertSame('Bob', $user->name); 53 | $this->assertSame('Baker Street', $user->address->street); 54 | $this->assertSame('London', $user->address->city); 55 | $this->assertNull($user->address->postcode); 56 | $this->assertSame('GB', $user->address->countryCode); 57 | $this->assertSame(3, $user->eventCount); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/GatewayTest.php: -------------------------------------------------------------------------------- 1 | add($country); 30 | 31 | $this->assertDebugStatementCount(1); 32 | $this->assertDebugStatement(0, 'INSERT INTO Country (code, name) VALUES (?, ?)', 'GB', 'United Kingdom'); 33 | } 34 | 35 | #[Depends('testAddCountry')] 36 | public function testLoadUnknownCountry() : void 37 | { 38 | $this->assertNull(self::$countryRepository->load('XX')); 39 | 40 | $this->assertDebugStatementCount(1); 41 | $this->assertDebugStatement(0, 'SELECT a.code, a.name FROM Country AS a WHERE a.code = ?', 'XX'); 42 | } 43 | 44 | #[Depends('testLoadUnknownCountry')] 45 | public function testLoadCountry() : Country 46 | { 47 | $country = self::$countryRepository->load('GB'); 48 | 49 | $this->assertSame('GB', $country->getCode()); 50 | $this->assertSame('United Kingdom', $country->getName()); 51 | 52 | $this->assertDebugStatementCount(1); 53 | $this->assertDebugStatement(0, 'SELECT a.code, a.name FROM Country AS a WHERE a.code = ?', 'GB'); 54 | 55 | return $country; 56 | } 57 | 58 | #[Depends('testLoadCountry')] 59 | public function testAddUser(Country $country) : User 60 | { 61 | $user = new User('John Smith'); 62 | 63 | $billingAddress = new Address('123 Unknown Road', 'London', 'WC2E9XX', $country, false); 64 | $user->setBillingAddress($billingAddress); 65 | 66 | self::$userRepository->add($user); 67 | 68 | $this->assertDebugStatementCount(1); 69 | $this->assertDebugStatement(0, 70 | 'INSERT INTO User (name, street, city, zipcode, country_code, isPoBox, ' . 71 | 'deliveryAddress_address_street, deliveryAddress_address_city, deliveryAddress_address_zipcode, ' . 72 | 'deliveryAddress_address_country_code, deliveryAddress_address_isPoBox, deliveryAddress_location, ' . 73 | 'lastEvent_type, lastEvent_id, data) ' . 74 | 'VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?)', 75 | 'John Smith', '123 Unknown Road', 'London', 'WC2E9XX', 'GB', false, '{"any":"data"}' 76 | ); 77 | 78 | // User ID must be set after saving 79 | $this->assertIsInt($user->getId()); 80 | $this->assertGreaterThan(0, $user->getId()); 81 | 82 | return $user; 83 | } 84 | 85 | #[Depends('testAddUser')] 86 | public function testUpdateUser(User $user) : int 87 | { 88 | $address = $user->getBillingAddress(); 89 | $location = new Geometry('POINT (51 0)', 4326); 90 | 91 | $user->setDeliveryAddress(new GeoAddress($address, $location)); 92 | self::$userRepository->update($user); 93 | 94 | $this->assertDebugStatementCount(1); 95 | $this->assertDebugStatement(0, 96 | 'UPDATE User SET name = ?, street = ?, city = ?, zipcode = ?, country_code = ?, isPoBox = ?, ' . 97 | 'deliveryAddress_address_street = ?, deliveryAddress_address_city = ?, ' . 98 | 'deliveryAddress_address_zipcode = ?, deliveryAddress_address_country_code = ?, ' . 99 | 'deliveryAddress_address_isPoBox = ?, deliveryAddress_location = ST_GeomFromText(?, ?), ' . 100 | 'lastEvent_type = NULL, lastEvent_id = NULL, data = ? ' . 101 | 'WHERE id = ?', 102 | 'John Smith', 103 | '123 Unknown Road', 'London', 'WC2E9XX', 'GB', false, 104 | '123 Unknown Road', 'London', 'WC2E9XX', 'GB', false, 105 | 'POINT (51 0)', 4326, '{"any":"data"}', $user->getId() 106 | ); 107 | 108 | return $user->getId(); 109 | } 110 | 111 | #[Depends('testUpdateUser')] 112 | public function testLoadPartialUser(int $userId) : int 113 | { 114 | $user = self::$userRepository->load($userId, 0, 'name'); 115 | 116 | $this->assertDebugStatementCount(1); 117 | $this->assertDebugStatement(0, 'SELECT a.name FROM User AS a WHERE a.id = ?', $userId); 118 | 119 | $this->assertSame('John Smith', $user->getName()); 120 | $this->assertSame([], $user->getTransient()); 121 | 122 | try { 123 | $user->getBillingAddress(); 124 | } catch (\Error $error) { 125 | if (strpos($error->getMessage(), 'must not be accessed before initialization') !== false) { 126 | goto ok; 127 | } 128 | } 129 | 130 | $this->fail('This property should not be set in partial object.'); 131 | 132 | ok: 133 | 134 | return $userId; 135 | } 136 | 137 | #[Depends('testLoadPartialUser')] 138 | public function testLoadUser(int $userId) : User 139 | { 140 | $user = self::$userRepository->load($userId); 141 | 142 | $this->assertDebugStatementCount(1); 143 | $this->assertDebugStatement(0, 144 | 'SELECT a.id, a.name, a.street, a.city, a.zipcode, a.country_code, a.isPoBox, ' . 145 | 'a.deliveryAddress_address_street, a.deliveryAddress_address_city, a.deliveryAddress_address_zipcode, ' . 146 | 'a.deliveryAddress_address_country_code, a.deliveryAddress_address_isPoBox, ' . 147 | 'ST_AsText(a.deliveryAddress_location), ST_SRID(a.deliveryAddress_location), ' . 148 | 'a.lastEvent_type, a.lastEvent_id, a.data ' . 149 | 'FROM User AS a WHERE a.id = ?', 150 | $userId 151 | ); 152 | 153 | $this->assertSame('John Smith', $user->getName()); 154 | $this->assertSame('123 Unknown Road', $user->getBillingAddress()->getStreet()); 155 | $this->assertSame('London', $user->getBillingAddress()->getCity()); 156 | $this->assertSame('WC2E9XX', $user->getBillingAddress()->getPostcode()); 157 | $this->assertSame('GB', $user->getBillingAddress()->getCountry()->getCode()); 158 | $this->assertSame('123 Unknown Road', $user->getDeliveryAddress()->getAddress()->getStreet()); 159 | $this->assertSame('London', $user->getDeliveryAddress()->getAddress()->getCity()); 160 | $this->assertSame('WC2E9XX', $user->getDeliveryAddress()->getAddress()->getPostcode()); 161 | $this->assertSame('GB', $user->getDeliveryAddress()->getAddress()->getCountry()->getCode()); 162 | $this->assertSame('POINT(51 0)', $user->getDeliveryAddress()->getLocation()->getWKT()); 163 | $this->assertSame(4326, $user->getDeliveryAddress()->getLocation()->getSRID()); 164 | $this->assertSame(['any' => 'data'], $user->getData()); 165 | 166 | return $user; 167 | } 168 | 169 | #[Depends('testLoadUser')] 170 | public function testRemoveUser(User $user) : int 171 | { 172 | self::$userRepository->remove($user); 173 | 174 | $this->assertDebugStatementCount(1); 175 | $this->assertDebugStatement(0, 'DELETE FROM User WHERE id = ?', $user->getId()); 176 | 177 | return $user->getId(); 178 | } 179 | 180 | #[Depends('testRemoveUser')] 181 | public function testLoadRemovedUser(int $userId) : void 182 | { 183 | $user = self::$userRepository->load($userId); 184 | $this->assertNull($user); 185 | 186 | $this->assertDebugStatementCount(1); 187 | $this->assertDebugStatement(0, 188 | 'SELECT a.id, a.name, a.street, a.city, a.zipcode, a.country_code, a.isPoBox, ' . 189 | 'a.deliveryAddress_address_street, a.deliveryAddress_address_city, a.deliveryAddress_address_zipcode, ' . 190 | 'a.deliveryAddress_address_country_code, a.deliveryAddress_address_isPoBox, ' . 191 | 'ST_AsText(a.deliveryAddress_location), ST_SRID(a.deliveryAddress_location), ' . 192 | 'a.lastEvent_type, a.lastEvent_id, a.data ' . 193 | 'FROM User AS a WHERE a.id = ?', 194 | $userId 195 | ); 196 | } 197 | 198 | /** 199 | * @param int $options 200 | * @param string $sqlSuffix 201 | * 202 | * @return void 203 | */ 204 | #[DataProvider('providerLoadWithLock')] 205 | public function testLoadWithLock(int $options, string $sqlSuffix) : void 206 | { 207 | self::$countryRepository->load('XX', $options); 208 | 209 | $this->assertDebugStatementCount(1); 210 | $this->assertDebugStatement(0, 'SELECT a.code, a.name FROM Country AS a WHERE a.code = ? ' . $sqlSuffix, 'XX'); 211 | } 212 | 213 | public static function providerLoadWithLock() : array 214 | { 215 | return [ 216 | [Options::LOCK_READ, 'FOR SHARE'], 217 | [Options::LOCK_WRITE, 'FOR UPDATE'], 218 | [Options::LOCK_READ | Options::SKIP_LOCKED, 'FOR SHARE SKIP LOCKED'], 219 | [Options::LOCK_WRITE | Options::SKIP_LOCKED, 'FOR UPDATE SKIP LOCKED'], 220 | [Options::LOCK_READ | Options::NOWAIT, 'FOR SHARE NOWAIT'], 221 | [Options::LOCK_WRITE | Options::NOWAIT, 'FOR UPDATE NOWAIT'], 222 | ]; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/CountryProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->name 48 | ); 49 | } 50 | 51 | /** 52 | * @param string $name 53 | * 54 | * @return mixed 55 | */ 56 | public function __get(string $name) 57 | { 58 | if (! $this->__isInitialized) { 59 | $loadProps = []; 60 | 61 | foreach (self::__NON_ID_PROPERTIES as $prop) { 62 | if (! isset($this->{$prop})) { // exclude initialized properties 63 | $loadProps[] = $prop; 64 | } 65 | } 66 | 67 | if ($loadProps) { 68 | $propValues = $this->__gateway->loadProps(Country::class, $this->__identity, $loadProps); 69 | 70 | if ($propValues === null) { 71 | throw EntityNotFoundException::entityNotFound(Country::class, $this->__scalarIdentity); 72 | } 73 | 74 | foreach ($propValues as $prop => $value) { 75 | $this->{$prop} = $value; 76 | } 77 | } 78 | 79 | $this->__isInitialized = true; 80 | } 81 | 82 | return $this->{$name}; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/CountryEvent/CreateCountryEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->country, 48 | $this->time 49 | ); 50 | } 51 | 52 | /** 53 | * @param string $name 54 | * 55 | * @return mixed 56 | */ 57 | public function __get(string $name) 58 | { 59 | if (! $this->__isInitialized) { 60 | $loadProps = []; 61 | 62 | foreach (self::__NON_ID_PROPERTIES as $prop) { 63 | if (! isset($this->{$prop})) { // exclude initialized properties 64 | $loadProps[] = $prop; 65 | } 66 | } 67 | 68 | if ($loadProps) { 69 | $propValues = $this->__gateway->loadProps(CreateCountryEvent::class, $this->__identity, $loadProps); 70 | 71 | if ($propValues === null) { 72 | throw EntityNotFoundException::entityNotFound(CreateCountryEvent::class, $this->__scalarIdentity); 73 | } 74 | 75 | foreach ($propValues as $prop => $value) { 76 | $this->{$prop} = $value; 77 | } 78 | } 79 | 80 | $this->__isInitialized = true; 81 | } 82 | 83 | return $this->{$name}; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/CountryEvent/EditCountryNameEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->newName, 48 | $this->country, 49 | $this->time 50 | ); 51 | } 52 | 53 | /** 54 | * @param string $name 55 | * 56 | * @return mixed 57 | */ 58 | public function __get(string $name) 59 | { 60 | if (! $this->__isInitialized) { 61 | $loadProps = []; 62 | 63 | foreach (self::__NON_ID_PROPERTIES as $prop) { 64 | if (! isset($this->{$prop})) { // exclude initialized properties 65 | $loadProps[] = $prop; 66 | } 67 | } 68 | 69 | if ($loadProps) { 70 | $propValues = $this->__gateway->loadProps(EditCountryNameEvent::class, $this->__identity, $loadProps); 71 | 72 | if ($propValues === null) { 73 | throw EntityNotFoundException::entityNotFound(EditCountryNameEvent::class, $this->__scalarIdentity); 74 | } 75 | 76 | foreach ($propValues as $prop => $value) { 77 | $this->{$prop} = $value; 78 | } 79 | } 80 | 81 | $this->__isInitialized = true; 82 | } 83 | 84 | return $this->{$name}; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/FollowUserEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->follower, 48 | $this->followee, 49 | $this->isFollow, 50 | $this->time 51 | ); 52 | } 53 | 54 | /** 55 | * @param string $name 56 | * 57 | * @return mixed 58 | */ 59 | public function __get(string $name) 60 | { 61 | if (! $this->__isInitialized) { 62 | $loadProps = []; 63 | 64 | foreach (self::__NON_ID_PROPERTIES as $prop) { 65 | if (! isset($this->{$prop})) { // exclude initialized properties 66 | $loadProps[] = $prop; 67 | } 68 | } 69 | 70 | if ($loadProps) { 71 | $propValues = $this->__gateway->loadProps(FollowUserEvent::class, $this->__identity, $loadProps); 72 | 73 | if ($propValues === null) { 74 | throw EntityNotFoundException::entityNotFound(FollowUserEvent::class, $this->__scalarIdentity); 75 | } 76 | 77 | foreach ($propValues as $prop => $value) { 78 | $this->{$prop} = $value; 79 | } 80 | } 81 | 82 | $this->__isInitialized = true; 83 | } 84 | 85 | return $this->{$name}; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/UserEvent/CreateUserEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->user, 48 | $this->time 49 | ); 50 | } 51 | 52 | /** 53 | * @param string $name 54 | * 55 | * @return mixed 56 | */ 57 | public function __get(string $name) 58 | { 59 | if (! $this->__isInitialized) { 60 | $loadProps = []; 61 | 62 | foreach (self::__NON_ID_PROPERTIES as $prop) { 63 | if (! isset($this->{$prop})) { // exclude initialized properties 64 | $loadProps[] = $prop; 65 | } 66 | } 67 | 68 | if ($loadProps) { 69 | $propValues = $this->__gateway->loadProps(CreateUserEvent::class, $this->__identity, $loadProps); 70 | 71 | if ($propValues === null) { 72 | throw EntityNotFoundException::entityNotFound(CreateUserEvent::class, $this->__scalarIdentity); 73 | } 74 | 75 | foreach ($propValues as $prop => $value) { 76 | $this->{$prop} = $value; 77 | } 78 | } 79 | 80 | $this->__isInitialized = true; 81 | } 82 | 83 | return $this->{$name}; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/UserEvent/EditUserBillingAddressEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->newAddress, 48 | $this->user, 49 | $this->time 50 | ); 51 | } 52 | 53 | /** 54 | * @param string $name 55 | * 56 | * @return mixed 57 | */ 58 | public function __get(string $name) 59 | { 60 | if (! $this->__isInitialized) { 61 | $loadProps = []; 62 | 63 | foreach (self::__NON_ID_PROPERTIES as $prop) { 64 | if (! isset($this->{$prop})) { // exclude initialized properties 65 | $loadProps[] = $prop; 66 | } 67 | } 68 | 69 | if ($loadProps) { 70 | $propValues = $this->__gateway->loadProps(EditUserBillingAddressEvent::class, $this->__identity, $loadProps); 71 | 72 | if ($propValues === null) { 73 | throw EntityNotFoundException::entityNotFound(EditUserBillingAddressEvent::class, $this->__scalarIdentity); 74 | } 75 | 76 | foreach ($propValues as $prop => $value) { 77 | $this->{$prop} = $value; 78 | } 79 | } 80 | 81 | $this->__isInitialized = true; 82 | } 83 | 84 | return $this->{$name}; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/UserEvent/EditUserDeliveryAddressEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->newAddress, 48 | $this->user, 49 | $this->time 50 | ); 51 | } 52 | 53 | /** 54 | * @param string $name 55 | * 56 | * @return mixed 57 | */ 58 | public function __get(string $name) 59 | { 60 | if (! $this->__isInitialized) { 61 | $loadProps = []; 62 | 63 | foreach (self::__NON_ID_PROPERTIES as $prop) { 64 | if (! isset($this->{$prop})) { // exclude initialized properties 65 | $loadProps[] = $prop; 66 | } 67 | } 68 | 69 | if ($loadProps) { 70 | $propValues = $this->__gateway->loadProps(EditUserDeliveryAddressEvent::class, $this->__identity, $loadProps); 71 | 72 | if ($propValues === null) { 73 | throw EntityNotFoundException::entityNotFound(EditUserDeliveryAddressEvent::class, $this->__scalarIdentity); 74 | } 75 | 76 | foreach ($propValues as $prop => $value) { 77 | $this->{$prop} = $value; 78 | } 79 | } 80 | 81 | $this->__isInitialized = true; 82 | } 83 | 84 | return $this->{$name}; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/Event/UserEvent/EditUserNameEventProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->newName, 48 | $this->user, 49 | $this->time 50 | ); 51 | } 52 | 53 | /** 54 | * @param string $name 55 | * 56 | * @return mixed 57 | */ 58 | public function __get(string $name) 59 | { 60 | if (! $this->__isInitialized) { 61 | $loadProps = []; 62 | 63 | foreach (self::__NON_ID_PROPERTIES as $prop) { 64 | if (! isset($this->{$prop})) { // exclude initialized properties 65 | $loadProps[] = $prop; 66 | } 67 | } 68 | 69 | if ($loadProps) { 70 | $propValues = $this->__gateway->loadProps(EditUserNameEvent::class, $this->__identity, $loadProps); 71 | 72 | if ($propValues === null) { 73 | throw EntityNotFoundException::entityNotFound(EditUserNameEvent::class, $this->__scalarIdentity); 74 | } 75 | 76 | foreach ($propValues as $prop => $value) { 77 | $this->{$prop} = $value; 78 | } 79 | } 80 | 81 | $this->__isInitialized = true; 82 | } 83 | 84 | return $this->{$name}; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/FollowProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->since 48 | ); 49 | } 50 | 51 | /** 52 | * @param string $name 53 | * 54 | * @return mixed 55 | */ 56 | public function __get(string $name) 57 | { 58 | if (! $this->__isInitialized) { 59 | $loadProps = []; 60 | 61 | foreach (self::__NON_ID_PROPERTIES as $prop) { 62 | if (! isset($this->{$prop})) { // exclude initialized properties 63 | $loadProps[] = $prop; 64 | } 65 | } 66 | 67 | if ($loadProps) { 68 | $propValues = $this->__gateway->loadProps(Follow::class, $this->__identity, $loadProps); 69 | 70 | if ($propValues === null) { 71 | throw EntityNotFoundException::entityNotFound(Follow::class, $this->__scalarIdentity); 72 | } 73 | 74 | foreach ($propValues as $prop => $value) { 75 | $this->{$prop} = $value; 76 | } 77 | } 78 | 79 | $this->__isInitialized = true; 80 | } 81 | 82 | return $this->{$name}; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Generated/Proxy/UserProxy.php: -------------------------------------------------------------------------------- 1 | __gateway = $gateway; 39 | $this->__identity = $identity; 40 | $this->__scalarIdentity = $scalarIdentity; 41 | 42 | foreach ($identity as $prop => $value) { 43 | $this->{$prop} = $value; 44 | } 45 | 46 | unset( 47 | $this->name, 48 | $this->billingAddress, 49 | $this->deliveryAddress, 50 | $this->lastEvent, 51 | $this->data 52 | ); 53 | } 54 | 55 | /** 56 | * @param string $name 57 | * 58 | * @return mixed 59 | */ 60 | public function __get(string $name) 61 | { 62 | if (! $this->__isInitialized) { 63 | $loadProps = []; 64 | 65 | foreach (self::__NON_ID_PROPERTIES as $prop) { 66 | if (! isset($this->{$prop})) { // exclude initialized properties 67 | $loadProps[] = $prop; 68 | } 69 | } 70 | 71 | if ($loadProps) { 72 | $propValues = $this->__gateway->loadProps(User::class, $this->__identity, $loadProps); 73 | 74 | if ($propValues === null) { 75 | throw EntityNotFoundException::entityNotFound(User::class, $this->__scalarIdentity); 76 | } 77 | 78 | foreach ($propValues as $prop => $value) { 79 | $this->{$prop} = $value; 80 | } 81 | } 82 | 83 | $this->__isInitialized = true; 84 | } 85 | 86 | return $this->{$name}; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Generated/Repository/CountryRepository.php: -------------------------------------------------------------------------------- 1 | gateway = $gateway; 28 | } 29 | 30 | public function load(string $code, int $options = 0, string ...$props) : ?Country 31 | { 32 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 33 | return $this->gateway->load(Country::class, ['code' => $code], $options, ...$props); 34 | } 35 | 36 | public function getReference(string $code) : Country 37 | { 38 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 39 | return $this->gateway->getReference(Country::class, ['code' => $code]); 40 | } 41 | 42 | public function exists(Country $country) : bool 43 | { 44 | return $this->gateway->exists($country); 45 | } 46 | 47 | public function existsIdentity(string $code) : bool 48 | { 49 | return $this->gateway->existsIdentity(Country::class, ['code' => $code]); 50 | } 51 | 52 | public function add(Country $country) : void 53 | { 54 | $this->gateway->add($country); 55 | } 56 | 57 | public function update(Country $country) : void 58 | { 59 | $this->gateway->update($country); 60 | } 61 | 62 | public function remove(Country $country) : void 63 | { 64 | $this->gateway->remove($country); 65 | } 66 | 67 | public function removeIdentity(string $code) : void 68 | { 69 | $this->gateway->removeIdentity(Country::class, ['code' => $code]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Generated/Repository/EventRepository.php: -------------------------------------------------------------------------------- 1 | gateway = $gateway; 28 | } 29 | 30 | public function load(int $id, int $options = 0, string ...$props) : ?Event 31 | { 32 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 33 | return $this->gateway->load(Event::class, ['id' => $id], $options, ...$props); 34 | } 35 | 36 | public function getReference(int $id) : Event 37 | { 38 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 39 | return $this->gateway->getReference(Event::class, ['id' => $id]); 40 | } 41 | 42 | public function exists(Event $event) : bool 43 | { 44 | return $this->gateway->exists($event); 45 | } 46 | 47 | public function existsIdentity(int $id) : bool 48 | { 49 | return $this->gateway->existsIdentity(Event::class, ['id' => $id]); 50 | } 51 | 52 | public function add(Event $event) : void 53 | { 54 | $this->gateway->add($event); 55 | } 56 | 57 | public function update(Event $event) : void 58 | { 59 | $this->gateway->update($event); 60 | } 61 | 62 | public function remove(Event $event) : void 63 | { 64 | $this->gateway->remove($event); 65 | } 66 | 67 | public function removeIdentity(int $id) : void 68 | { 69 | $this->gateway->removeIdentity(Event::class, ['id' => $id]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Generated/Repository/FollowRepository.php: -------------------------------------------------------------------------------- 1 | gateway = $gateway; 29 | } 30 | 31 | public function load(User $follower, User $followee, int $options = 0, string ...$props) : ?Follow 32 | { 33 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 34 | return $this->gateway->load(Follow::class, ['follower' => $follower, 'followee' => $followee], $options, ...$props); 35 | } 36 | 37 | public function getReference(User $follower, User $followee) : Follow 38 | { 39 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 40 | return $this->gateway->getReference(Follow::class, ['follower' => $follower, 'followee' => $followee]); 41 | } 42 | 43 | public function exists(Follow $follow) : bool 44 | { 45 | return $this->gateway->exists($follow); 46 | } 47 | 48 | public function existsIdentity(User $follower, User $followee) : bool 49 | { 50 | return $this->gateway->existsIdentity(Follow::class, ['follower' => $follower, 'followee' => $followee]); 51 | } 52 | 53 | public function add(Follow $follow) : void 54 | { 55 | $this->gateway->add($follow); 56 | } 57 | 58 | public function update(Follow $follow) : void 59 | { 60 | $this->gateway->update($follow); 61 | } 62 | 63 | public function remove(Follow $follow) : void 64 | { 65 | $this->gateway->remove($follow); 66 | } 67 | 68 | public function removeIdentity(User $follower, User $followee) : void 69 | { 70 | $this->gateway->removeIdentity(Follow::class, ['follower' => $follower, 'followee' => $followee]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Generated/Repository/UserRepository.php: -------------------------------------------------------------------------------- 1 | gateway = $gateway; 28 | } 29 | 30 | public function load(int $id, int $options = 0, string ...$props) : ?User 31 | { 32 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 33 | return $this->gateway->load(User::class, ['id' => $id], $options, ...$props); 34 | } 35 | 36 | public function getReference(int $id) : User 37 | { 38 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 39 | return $this->gateway->getReference(User::class, ['id' => $id]); 40 | } 41 | 42 | public function exists(User $user) : bool 43 | { 44 | return $this->gateway->exists($user); 45 | } 46 | 47 | public function existsIdentity(int $id) : bool 48 | { 49 | return $this->gateway->existsIdentity(User::class, ['id' => $id]); 50 | } 51 | 52 | public function add(User $user) : void 53 | { 54 | $this->gateway->add($user); 55 | } 56 | 57 | public function update(User $user) : void 58 | { 59 | $this->gateway->update($user); 60 | } 61 | 62 | public function remove(User $user) : void 63 | { 64 | $this->gateway->remove($user); 65 | } 66 | 67 | public function removeIdentity(int $id) : void 68 | { 69 | $this->gateway->removeIdentity(User::class, ['id' => $id]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/ObjectFactoryTest.php: -------------------------------------------------------------------------------- 1 | className = User::class; 31 | $testClassMetadata->properties = []; 32 | 33 | $objectFactory = new ObjectFactory(); 34 | $user = $objectFactory->instantiate($testClassMetadata); 35 | 36 | $this->assertSame(User::class, get_class($user)); 37 | 38 | $this->assertSame([ 39 | "\0*\0billingAddress" => null, 40 | "\0*\0deliveryAddress" => null, 41 | "\0*\0lastEvent" => null, 42 | "\0*\0data" => ['any' => 'data'], 43 | "\0*\0transient" => [] 44 | ], (array) $user); 45 | 46 | $this->assertSame([ 47 | 'billingAddress' => null, 48 | 'deliveryAddress' => null, 49 | 'lastEvent' => null, 50 | 'data' => ['any' => 'data'], 51 | 'transient' => [] 52 | ], $objectFactory->read($user)); 53 | } 54 | 55 | public function testInstantiateWithPersistentProps() 56 | { 57 | $testClassMetadata = new EntityMetadata(); 58 | $testClassMetadata->className = User::class; 59 | $testClassMetadata->properties = ['id', 'name', 'billingAddress', 'deliveryAddress', 'lastEvent', 'data']; 60 | 61 | $objectFactory = new ObjectFactory(); 62 | $user = $objectFactory->instantiate($testClassMetadata); 63 | 64 | $this->assertSame(User::class, get_class($user)); 65 | 66 | $this->assertSame([ 67 | "\0*\0transient" => [] 68 | ], (array) $user); 69 | 70 | $this->assertSame([ 71 | 'transient' => [] 72 | ], $objectFactory->read($user)); 73 | } 74 | 75 | public function testWrite() 76 | { 77 | $values = [ 78 | 'name' => 'John' 79 | ]; 80 | 81 | $testClassMetadata = new EntityMetadata(); 82 | $testClassMetadata->className = User::class; 83 | $testClassMetadata->properties = ['id', 'name', 'billingAddress', 'deliveryAddress', 'lastEvent', 'data']; 84 | 85 | $objectFactory = new ObjectFactory(); 86 | $user = $objectFactory->instantiate($testClassMetadata); 87 | $objectFactory->write($user, $values); 88 | 89 | $this->assertSame(User::class, get_class($user)); 90 | 91 | $this->assertSame([ 92 | "\0*\0name" => 'John', 93 | "\0*\0transient" => [] 94 | ], (array) $user); 95 | 96 | $this->assertSame([ 97 | 'name' => 'John', 98 | 'transient' => [] 99 | ], $objectFactory->read($user)); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/ProxyTest.php: -------------------------------------------------------------------------------- 1 | add($country); 31 | 32 | $user = new User('John Smith'); 33 | 34 | $billingAddress = new Address('123 Unknown Road', 'London', 'WC2E9XX', $country, false); 35 | $user->setBillingAddress($billingAddress); 36 | 37 | self::$userRepository->add($user); 38 | self::$logger->reset(); 39 | 40 | return $user->getId(); 41 | })(); 42 | 43 | // reload the user 44 | 45 | $user = self::$userRepository->load($userId); 46 | 47 | $this->assertDebugStatementCount(1); 48 | $this->assertDebugStatement(0, 49 | 'SELECT a.id, a.name, a.street, a.city, a.zipcode, a.country_code, a.isPoBox, a.deliveryAddress_address_street, ' . 50 | 'a.deliveryAddress_address_city, a.deliveryAddress_address_zipcode, a.deliveryAddress_address_country_code, ' . 51 | 'a.deliveryAddress_address_isPoBox, ST_AsText(a.deliveryAddress_location), ST_SRID(a.deliveryAddress_location), ' . 52 | 'a.lastEvent_type, a.lastEvent_id, a.data ' . 53 | 'FROM User AS a WHERE a.id = ?', 54 | $userId 55 | ); 56 | 57 | $country = $user->getBillingAddress()->getCountry(); 58 | 59 | // Using an identity property: should be readily available and not trigger lazy initialization 60 | $this->assertSame('GB', $country->getCode()); 61 | $this->assertDebugStatementCount(1); 62 | 63 | // Using a non-identity property: should initialize the proxy 64 | $this->assertSame('United Kingdom', $country->getName()); 65 | $this->assertDebugStatementCount(2); 66 | $this->assertDebugStatement(1, 'SELECT a.name FROM Country AS a WHERE a.code = ?', 'GB'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Resources/DTO/AddressDTO.php: -------------------------------------------------------------------------------- 1 | fieldName = $fieldName; 27 | $this->isNullable = $isNullable; 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function getType() : ?string 34 | { 35 | return Geometry::class; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function isNullable() : bool 42 | { 43 | return $this->isNullable; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function getFieldNames(): array 50 | { 51 | return [$this->fieldName]; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function getInputValuesCount() : int 58 | { 59 | return 2; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function getFieldToInputValuesSQL(array $fieldNames) : array 66 | { 67 | return [ 68 | 'ST_AsText(' . $fieldNames[0] . ')', 69 | 'ST_SRID(' . $fieldNames[0] . ')' 70 | ]; 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | */ 76 | public function convertInputValuesToProp(Gateway $gateway, array $values) : mixed 77 | { 78 | [$wkt, $srid] = $values; 79 | 80 | if ($wkt === null) { 81 | return null; 82 | } 83 | 84 | return new Geometry($wkt, (int) $srid); 85 | } 86 | 87 | /** 88 | * {@inheritdoc} 89 | */ 90 | public function convertPropToFields(mixed $propValue) : array 91 | { 92 | if ($propValue === null) { 93 | return [ 94 | ['NULL'] 95 | ]; 96 | } 97 | 98 | /** @var Geometry $propValue */ 99 | return [ 100 | ['ST_GeomFromText(?, ?)', $propValue->getWKT(), $propValue->getSRID()] 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/Resources/Models/Address.php: -------------------------------------------------------------------------------- 1 | street = $street; 28 | $this->city = $city; 29 | $this->postcode = $postcode; 30 | $this->country = $country; 31 | $this->isPoBox = $isPoBox; 32 | } 33 | 34 | public function getStreet() : string 35 | { 36 | return $this->street; 37 | } 38 | 39 | public function getCity() : string 40 | { 41 | return $this->city; 42 | } 43 | 44 | public function getPostcode() : string 45 | { 46 | return $this->postcode; 47 | } 48 | 49 | public function getCountry() : Country 50 | { 51 | return $this->country; 52 | } 53 | 54 | public function isPoBox() : bool 55 | { 56 | return $this->isPoBox; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Resources/Models/Country.php: -------------------------------------------------------------------------------- 1 | code = $code; 19 | $this->name = $name; 20 | } 21 | 22 | public function getCode() : string 23 | { 24 | return $this->code; 25 | } 26 | 27 | public function getName() : string 28 | { 29 | return $this->name; 30 | } 31 | 32 | public function setName(string $name) : void 33 | { 34 | $this->name = $name; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event.php: -------------------------------------------------------------------------------- 1 | time = 1234567890; // hardcoded for tests 19 | } 20 | 21 | public function getId() : int 22 | { 23 | return $this->id; 24 | } 25 | 26 | public function getTime() : int 27 | { 28 | return $this->time; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/CountryEvent.php: -------------------------------------------------------------------------------- 1 | country = $country; 19 | } 20 | 21 | public function getCountry() : Country 22 | { 23 | return $this->country; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/CountryEvent/CreateCountryEvent.php: -------------------------------------------------------------------------------- 1 | newName = $newName; 19 | } 20 | 21 | public function getNewName() : string 22 | { 23 | return $this->newName; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/FollowUserEvent.php: -------------------------------------------------------------------------------- 1 | follower = $follower; 23 | $this->followee = $followee; 24 | $this->isFollow = $isFollow; 25 | } 26 | 27 | public function getFollower() : User 28 | { 29 | return $this->follower; 30 | } 31 | 32 | public function getFollowee() : User 33 | { 34 | return $this->followee; 35 | } 36 | 37 | public function isFollow() : bool 38 | { 39 | return $this->isFollow; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/UserEvent.php: -------------------------------------------------------------------------------- 1 | user = $user; 19 | } 20 | 21 | public function getUser() : User 22 | { 23 | return $this->user; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/UserEvent/CreateUserEvent.php: -------------------------------------------------------------------------------- 1 | newAddress = $newBillingAddress; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/UserEvent/EditUserDeliveryAddressEvent.php: -------------------------------------------------------------------------------- 1 | newAddress = $newDeliveryAddress; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Resources/Models/Event/UserEvent/EditUserNameEvent.php: -------------------------------------------------------------------------------- 1 | newName = $newName; 19 | } 20 | 21 | public function getNewName() : string 22 | { 23 | return $this->newName; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Resources/Models/Follow.php: -------------------------------------------------------------------------------- 1 | follower = $follower; 21 | $this->followee = $followee; 22 | $this->since = time(); 23 | } 24 | 25 | public function getFollower() : User 26 | { 27 | return $this->follower; 28 | } 29 | 30 | public function getFollowee() : User 31 | { 32 | return $this->followee; 33 | } 34 | 35 | public function getSince() : int 36 | { 37 | return $this->since; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Resources/Models/GeoAddress.php: -------------------------------------------------------------------------------- 1 | address = $address; 21 | $this->location = $location; 22 | } 23 | 24 | /** 25 | * @return Address 26 | */ 27 | public function getAddress() : Address 28 | { 29 | return $this->address; 30 | } 31 | 32 | /** 33 | * @return Geometry 34 | */ 35 | public function getLocation() : Geometry 36 | { 37 | return $this->location; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Resources/Models/User.php: -------------------------------------------------------------------------------- 1 | 'data']; 38 | 39 | /** 40 | * A transient property, that should not be persisted. 41 | */ 42 | protected array $transient = []; 43 | 44 | public function __construct(string $name) 45 | { 46 | $this->name = $name; 47 | } 48 | 49 | public function getId() : int 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function getName() : string 55 | { 56 | return $this->name; 57 | } 58 | 59 | public function setName(string $name) : void 60 | { 61 | $this->name = $name; 62 | } 63 | 64 | public function getBillingAddress() : ?Address 65 | { 66 | return $this->billingAddress; 67 | } 68 | 69 | public function setBillingAddress(?Address $billingAddress) : void 70 | { 71 | $this->billingAddress = $billingAddress; 72 | } 73 | 74 | public function getDeliveryAddress() : ?GeoAddress 75 | { 76 | return $this->deliveryAddress; 77 | } 78 | 79 | public function setDeliveryAddress(?GeoAddress $deliveryAddress) : void 80 | { 81 | $this->deliveryAddress = $deliveryAddress; 82 | } 83 | 84 | public function getData() : array 85 | { 86 | return $this->data; 87 | } 88 | 89 | public function getTransient() : array 90 | { 91 | return $this->transient; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Resources/Objects/Geometry.php: -------------------------------------------------------------------------------- 1 | wkt = $wkt; 16 | $this->srid = $srid; 17 | } 18 | 19 | public function getWKT() : string 20 | { 21 | return $this->wkt; 22 | } 23 | 24 | public function getSRID() : int 25 | { 26 | return $this->srid; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/TableAliasGeneratorTest.php: -------------------------------------------------------------------------------- 1 | generate()); 28 | } 29 | } 30 | } 31 | --------------------------------------------------------------------------------