├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── infection.json └── src ├── Compiler.php ├── Defaults.php ├── Definition ├── Comparator │ └── FieldComparator.php ├── Entity.php ├── Field.php ├── ForeignKey.php ├── Inheritance.php ├── Inheritance │ ├── JoinedTable.php │ └── SingleTable.php ├── Map │ ├── FieldMap.php │ ├── ForeignKeyMap.php │ ├── OptionMap.php │ └── RelationMap.php └── Relation.php ├── Exception ├── ColumnException.php ├── CompilerException.php ├── EntityException.php ├── FieldException.php ├── FieldException │ └── EmbeddedPrimaryKeyException.php ├── OptionException.php ├── RegistryException.php ├── RelationException.php ├── SchemaException.php ├── SchemaModifierException.php ├── SyncException.php ├── TableInheritance │ ├── DiscriminatorColumnNotPresentException.php │ ├── WrongDiscriminatorColumnException.php │ └── WrongParentKeyColumnException.php └── TableInheritanceException.php ├── Generator ├── ForeignKeys.php ├── GenerateModifiers.php ├── GenerateRelations.php ├── GenerateTypecast.php ├── PrintChanges.php ├── RenderModifiers.php ├── RenderRelations.php ├── RenderTables.php ├── ResetTables.php ├── ResolveInterfaces.php ├── SyncTables.php └── ValidateEntities.php ├── GeneratorInterface.php ├── InversableInterface.php ├── Registry.php ├── Relation ├── BelongsTo.php ├── Embedded.php ├── HasMany.php ├── HasOne.php ├── ManyToMany.php ├── Morphed │ ├── BelongsToMorphed.php │ ├── MorphedHasMany.php │ └── MorphedHasOne.php ├── OptionSchema.php ├── RefersTo.php ├── RelationSchema.php └── Traits │ ├── FieldTrait.php │ ├── ForeignKeyTrait.php │ └── MorphTrait.php ├── RelationInterface.php ├── SchemaModifierInterface.php └── Table └── Column.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@spiralscout.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Feel free to contribute to the development of the Cycle ORM Schema Builder. 3 | Please make sure that the following requirements are satisfied before submitting your pull request: 4 | 5 | * KISS 6 | * PSR-12 7 | * `declare(strict_types=1);` is mandatory 8 | * Your code must include tests 9 | 10 | > Use our discord server to check for the advice or suggestion https://discord.gg/FZ9BCWg 11 | 12 | ## Testing Cycle ORM Schema Builder 13 | To test Cycle ORM Schema Builder locally, download the `cycle/schema-builder` repository and start docker containers inside the tests folder: 14 | 15 | ```bash 16 | $ cd tests/ 17 | $ docker-composer up 18 | ``` 19 | 20 | To run full test suite: 21 | 22 | ```bash 23 | $ ./vendor/bin/phpunit 24 | ``` 25 | 26 | To run quick test suite: 27 | 28 | ```bash 29 | $ ./vendor/bin/phpunit tests/Schema/Driver/SQLite 30 | ``` 31 | 32 | ## Help Needed In 33 | If you want to help but don't know where to start: 34 | 35 | * TODOs 36 | * Updating to latest dev-dependencies (PHPUnit, Mockery, etc) 37 | * Quality recommendations and improvements 38 | * Check [Open Issues](https://github.com/cycle/schema-builder/issues) 39 | * More tests are always welcome 40 | * Typos 41 | 42 | Feel free to propose any ideas related to architecture, docs (___docs are never complete___), adaptation or community. 43 | 44 | > Original guide author is not a native English speaker, feel free to create PR for any text corrections. 45 | 46 | ## Critical/Security Issues 47 | If you found something which shouldn't be there or a bug which opens a security hole please let me know immediately by email 48 | [team@spiralscout.com](mailto:team@spiralscout.com) 49 | 50 | ## Official Support 51 | Cycle ORM Schema Builder are maintained by [Spiral Scout](https://spiralscout.com/). 52 | 53 | For commercial support please contact team@spiralscout.com. 54 | 55 | ## Licensing 56 | Cycle ORM Schema Builder will remain under [MIT license](/license.md) indefinitely. 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Spiral Scout 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cycle ORM - Schema Builder 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/cycle/schema-builder/version)](https://packagist.org/packages/cycle/schema-builder) 4 | [![Build Status](https://github.com/cycle/schema-builder/workflows/build/badge.svg)](https://github.com/cycle/schema-builder/actions) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/cycle/schema-builder/badges/quality-score.png?b=2.x)](https://scrutinizer-ci.com/g/cycle/schema-builder/?branch=2.x) 6 | [![Codecov](https://codecov.io/gh/cycle/schema-builder/graph/badge.svg)](https://codecov.io/gh/cycle/schema-builder) 7 | 8 | Schema Builder package provides a convenient way to configure your ORM and Database schema via 9 | [annotations (attributes)](https://github.com/cycle/annotated) or custom generators. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | composer require cycle/schema-builder 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ```php 20 | use Cycle\Migrations; 21 | use Cycle\Database; 22 | use Cycle\Database\Config; 23 | 24 | $dbal = new Database\DatabaseManager(new Config\DatabaseConfig([ 25 | 'default' => 'default', 26 | 'databases' => [ 27 | 'default' => [ 28 | 'connection' => 'sqlite' 29 | ] 30 | ], 31 | 'connections' => [ 32 | 'sqlite' => new Config\SQLiteDriverConfig( 33 | connection: new Config\SQLite\MemoryConnectionConfig(), 34 | queryCache: true, 35 | ), 36 | ] 37 | ])); 38 | 39 | $registry = new \Cycle\Schema\Registry($dbal); 40 | ``` 41 | 42 | We can now register our first entity, add its columns and link to a specific table: 43 | 44 | ```php 45 | use Cycle\Schema\Definition; 46 | 47 | $entity = new Definition\Entity(); 48 | 49 | $entity 50 | ->setRole('user') 51 | ->setClass(User::class); 52 | 53 | // add fields 54 | $entity->getFields() 55 | ->set('id', (new Definition\Field())->setType('primary')->setColumn('id')->setPrimary(true)) 56 | ->set('name', (new Definition\Field())->setType('string(32)')->setColumn('user_name')); 57 | 58 | // register entity 59 | $r->register($entity); 60 | 61 | // associate table 62 | $r->linkTable($entity, 'default', 'users'); 63 | ``` 64 | You can generate ORM schema immediately using `Cycle\Schema\Compiler`: 65 | 66 | ```php 67 | use Cycle\Schema\Compiler; 68 | $schema = (new Compiler())->compile($r); 69 | 70 | $orm = $orm->with(schema: new \Cycle\ORM\Schema($schema)); 71 | ``` 72 | 73 | You can find more information about Schema builder package [here](https://cycle-orm.dev/docs/schema-dynamic-schema#using-schema-builder). 74 | 75 | License: 76 | -------- 77 | The MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained 78 | by [Spiral Scout](https://spiralscout.com). 79 | 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle/schema-builder", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "Cycle ORM Schema Builder", 6 | "minimum-stability": "dev", 7 | "prefer-stable": true, 8 | "authors": [ 9 | { 10 | "name": "Anton Titov (wolfy-j)", 11 | "email": "wolfy-j@spiralscout.com" 12 | }, 13 | { 14 | "name": "Aleksei Gagarin (roxblnfk)", 15 | "email": "alexey.gagarin@spiralscout.com" 16 | }, 17 | { 18 | "name": "Pavel Butchnev (butschster)", 19 | "email": "pavel.buchnev@spiralscout.com" 20 | }, 21 | { 22 | "name": "Maksim Smakouz (msmakouz)", 23 | "email": "maksim.smakouz@spiralscout.com" 24 | } 25 | ], 26 | "funding": [ 27 | { 28 | "type": "github", 29 | "url": "https://github.com/sponsors/cycle" 30 | } 31 | ], 32 | "require": { 33 | "php": ">=8.0", 34 | "cycle/orm": "^2.7", 35 | "cycle/database": "^2.7.1", 36 | "yiisoft/friendly-exception": "^1.1" 37 | }, 38 | "require-dev": { 39 | "phpunit/phpunit": "^9.5", 40 | "spiral/tokenizer": "^2.8", 41 | "vimeo/psalm": "^5.12", 42 | "symfony/console": "^6.0 || ^7.0", 43 | "spiral/code-style": "^2.2" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Cycle\\Schema\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Cycle\\Schema\\Tests\\": "tests/Schema/" 53 | } 54 | }, 55 | "scripts": { 56 | "cs:diff": "php-cs-fixer fix --dry-run -v --diff", 57 | "cs:fix": "php-cs-fixer fix -v", 58 | "psalm": "psalm", 59 | "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml", 60 | "test": "phpunit --color=always" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /infection.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "infection.log" 10 | }, 11 | "mutators": { 12 | "@default": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Compiler.php: -------------------------------------------------------------------------------- 1 | > */ 22 | private array $result = []; 23 | 24 | /** 25 | * Compile the registry schema. 26 | * 27 | * @param GeneratorInterface[] $generators 28 | */ 29 | public function compile(Registry $registry, array $generators = [], array $defaults = []): array 30 | { 31 | $registry->getDefaults()->merge($defaults); 32 | 33 | foreach ($generators as $generator) { 34 | if (!$generator instanceof GeneratorInterface) { 35 | throw new CompilerException( 36 | sprintf( 37 | 'Invalid generator `%s`. It should implement `%s` interface.', 38 | \is_object($generator) ? $generator::class : \var_export($generator, true), 39 | GeneratorInterface::class, 40 | ), 41 | ); 42 | } 43 | 44 | $registry = $generator->run($registry); 45 | } 46 | 47 | foreach ($registry->getIterator() as $entity) { 48 | if ($entity->hasPrimaryKey() || $entity->isChildOfSingleTableInheritance()) { 49 | $this->compute($registry, $entity); 50 | } 51 | } 52 | 53 | return $this->result; 54 | } 55 | 56 | /** 57 | * Get compiled schema result. 58 | */ 59 | public function getSchema(): array 60 | { 61 | return $this->result; 62 | } 63 | 64 | /** 65 | * Compile entity and relation definitions into packed ORM schema. 66 | */ 67 | private function compute(Registry $registry, Entity $entity): void 68 | { 69 | $defaults = $registry->getDefaults(); 70 | $role = $entity->getRole(); 71 | \assert($role !== null); 72 | 73 | $schema = [ 74 | Schema::ENTITY => $entity->getClass(), 75 | Schema::SOURCE => $entity->getSource() ?? $defaults[Schema::SOURCE], 76 | Schema::MAPPER => $entity->getMapper() ?? $defaults[Schema::MAPPER], 77 | Schema::REPOSITORY => $entity->getRepository() ?? $defaults[Schema::REPOSITORY], 78 | Schema::SCOPE => $entity->getScope() ?? $defaults[Schema::SCOPE], 79 | Schema::SCHEMA => $entity->getSchema(), 80 | Schema::TYPECAST_HANDLER => $this->renderTypecastHandler($registry->getDefaults(), $entity), 81 | Schema::PRIMARY_KEY => $entity->getPrimaryFields()->getNames(), 82 | Schema::COLUMNS => $this->renderColumns($entity), 83 | Schema::FIND_BY_KEYS => $this->renderReferences($entity), 84 | Schema::TYPECAST => $this->renderTypecast($entity), 85 | Schema::RELATIONS => [], 86 | Schema::GENERATED_FIELDS => $this->renderGeneratedFields($entity), 87 | ]; 88 | 89 | // For table inheritance we need to fill specific schema segments 90 | $inheritance = $entity->getInheritance(); 91 | if ($inheritance instanceof SingleTable) { 92 | // Check if discriminator column defined and is not null or empty 93 | $discriminator = $inheritance->getDiscriminator(); 94 | if ($discriminator === null || $discriminator === '') { 95 | throw new DiscriminatorColumnNotPresentException($entity); 96 | } 97 | if (!$entity->getFields()->has($discriminator)) { 98 | throw new WrongDiscriminatorColumnException($entity, $discriminator); 99 | } 100 | 101 | $schema[Schema::CHILDREN] = $inheritance->getChildren(); 102 | $schema[Schema::DISCRIMINATOR] = $discriminator; 103 | } elseif ($inheritance instanceof JoinedTable) { 104 | $schema[Schema::PARENT] = $inheritance->getParent()->getRole(); 105 | assert($schema[Schema::PARENT] !== null); 106 | 107 | $parent = $registry->getEntity($schema[Schema::PARENT]); 108 | if ($inheritance->getOuterKey()) { 109 | if (!$parent->getFields()->has($inheritance->getOuterKey())) { 110 | throw new WrongParentKeyColumnException($parent, $inheritance->getOuterKey()); 111 | } 112 | $schema[Schema::PARENT_KEY] = $inheritance->getOuterKey(); 113 | } 114 | } 115 | 116 | $this->renderRelations($registry, $entity, $schema); 117 | 118 | if ($registry->hasTable($entity)) { 119 | $schema[Schema::DATABASE] = $registry->getDatabase($entity); 120 | $schema[Schema::TABLE] = $registry->getTable($entity); 121 | } 122 | 123 | // Apply modifiers 124 | foreach ($entity->getSchemaModifiers() as $modifier) { 125 | \assert($modifier instanceof SchemaModifierInterface); 126 | try { 127 | $modifier->modifySchema($schema); 128 | } catch (\Throwable $e) { 129 | throw new SchemaModifierException( 130 | sprintf( 131 | 'Unable to apply schema modifier `%s` for the `%s` role. %s', 132 | $modifier::class, 133 | $role, 134 | $e->getMessage(), 135 | ), 136 | (int) $e->getCode(), 137 | $e, 138 | ); 139 | } 140 | } 141 | 142 | // For STI child we need only schema role as a key and entity segment 143 | if ($entity->isChildOfSingleTableInheritance()) { 144 | $schema = \array_intersect_key($schema, [Schema::ENTITY, Schema::ROLE]); 145 | } 146 | 147 | /** @var array $schema */ 148 | ksort($schema); 149 | 150 | $this->result[$role] = $schema; 151 | } 152 | 153 | private function renderColumns(Entity $entity): array 154 | { 155 | // Check field duplicates 156 | /** @var Field[][] $fieldGroups */ 157 | $fieldGroups = []; 158 | // Collect and group fields by column name 159 | foreach ($entity->getFields() as $name => $field) { 160 | $fieldGroups[$field->getColumn()][$name] = $field; 161 | } 162 | foreach ($fieldGroups as $fields) { 163 | // We need duplicates only 164 | if (count($fields) === 1) { 165 | continue; 166 | } 167 | // Compare 168 | $comparator = new FieldComparator(); 169 | foreach ($fields as $name => $field) { 170 | $comparator->addField($name, $field); 171 | } 172 | try { 173 | $comparator->compare(); 174 | } catch (\Throwable $e) { 175 | throw new Exception\CompilerException(sprintf( 176 | "Error compiling the `%s` role.\n\n%s", 177 | $entity->getRole() ?? 'unknown', 178 | $e->getMessage(), 179 | ), (int) $e->getCode()); 180 | } 181 | } 182 | 183 | $schema = []; 184 | foreach ($entity->getFields() as $name => $field) { 185 | $schema[$name] = $field->getColumn(); 186 | } 187 | 188 | return $schema; 189 | } 190 | 191 | private function renderGeneratedFields(Entity $entity): array 192 | { 193 | $schema = []; 194 | foreach ($entity->getFields() as $name => $field) { 195 | if ($field->getGenerated() !== null) { 196 | $schema[$name] = $field->getGenerated(); 197 | } 198 | } 199 | 200 | return $schema; 201 | } 202 | 203 | private function renderTypecast(Entity $entity): array 204 | { 205 | $schema = []; 206 | foreach ($entity->getFields() as $name => $field) { 207 | if ($field->hasTypecast()) { 208 | $schema[$name] = $field->getTypecast(); 209 | } 210 | } 211 | 212 | return $schema; 213 | } 214 | 215 | private function renderReferences(Entity $entity): array 216 | { 217 | $schema = $entity->getPrimaryFields()->getNames(); 218 | 219 | foreach ($entity->getFields() as $name => $field) { 220 | if ($field->isReferenced()) { 221 | $schema[] = $name; 222 | } 223 | } 224 | 225 | return array_unique($schema); 226 | } 227 | 228 | private function renderRelations(Registry $registry, Entity $entity, array &$schema): void 229 | { 230 | foreach ($registry->getRelations($entity) as $relation) { 231 | $relation->modifySchema($schema); 232 | } 233 | } 234 | 235 | private function renderTypecastHandler(Defaults $defaults, Entity $entity): array|null|string 236 | { 237 | $defaults = $defaults[Schema::TYPECAST_HANDLER] ?? []; 238 | if (!\is_array($defaults)) { 239 | $defaults = [$defaults]; 240 | } 241 | 242 | if ($defaults === []) { 243 | return $entity->getTypecast(); 244 | } 245 | 246 | $typecast = $entity->getTypecast() ?? []; 247 | 248 | return \array_values(\array_unique(\array_merge(\is_array($typecast) ? $typecast : [$typecast], $defaults))); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/Defaults.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class Defaults implements \ArrayAccess 16 | { 17 | /** 18 | * @param array $defaults 19 | */ 20 | public function __construct( 21 | private array $defaults = [ 22 | SchemaInterface::MAPPER => Mapper::class, 23 | SchemaInterface::REPOSITORY => Repository::class, 24 | SchemaInterface::SOURCE => Source::class, 25 | SchemaInterface::SCOPE => null, 26 | SchemaInterface::TYPECAST_HANDLER => null, 27 | ], 28 | ) {} 29 | 30 | /** 31 | * @param array $defaults 32 | */ 33 | public function merge(array $defaults): self 34 | { 35 | $this->defaults = $defaults + $this->defaults; 36 | 37 | return $this; 38 | } 39 | 40 | public function offsetExists(mixed $offset): bool 41 | { 42 | return isset($this->defaults[$offset]); 43 | } 44 | 45 | public function offsetGet(mixed $offset): mixed 46 | { 47 | return $this->defaults[$offset]; 48 | } 49 | 50 | /** 51 | * @param int $offset 52 | */ 53 | public function offsetSet(mixed $offset, mixed $value): void 54 | { 55 | $this->defaults[$offset] = $value; 56 | } 57 | 58 | public function offsetUnset(mixed $offset): void 59 | { 60 | unset($this->defaults[$offset]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Definition/Comparator/FieldComparator.php: -------------------------------------------------------------------------------- 1 | columnName === null) { 19 | $this->columnName = $field->getColumn(); 20 | } 21 | if ($this->columnName !== $field->getColumn()) { 22 | throw new \InvalidArgumentException('The field comparator only accepts fields with the same column name.'); 23 | } 24 | $this->fields[$key] = $field; 25 | return $this; 26 | } 27 | 28 | public function compare(): void 29 | { 30 | if (count($this->fields) <= 1) { 31 | return; 32 | } 33 | // Check options 34 | if (!$this->compareOptions() || !$this->compareProperties()) { 35 | throw new \Exception( 36 | "Different definitions are specified for the `$this->columnName` column:" 37 | . "\n\n{$this->generateErrorText()}", 38 | ); 39 | } 40 | } 41 | 42 | private function generateErrorText(): string 43 | { 44 | $lines = []; 45 | foreach ($this->fields as $key => $field) { 46 | $primary = $field->isPrimary() ? ' primary' : ''; 47 | $line = sprintf("%s:\n type=%s%s", $key, $field->getType(), $primary); 48 | // Print options 49 | foreach ($field->getOptions() as $optionName => $optionValue) { 50 | $line .= " {$optionName}=" . var_export($optionValue, true); 51 | } 52 | $lines[] = $line; 53 | } 54 | return implode("\n\n", $lines); 55 | } 56 | 57 | private function compareProperties(): bool 58 | { 59 | $tuples = array_map(static function (Field $field): array { 60 | return [ 61 | $field->getType(), 62 | // $field->isPrimary(), // should not compared 63 | ]; 64 | }, $this->fields); 65 | 66 | // Compare options content 67 | $prototype = array_shift($tuples); 68 | foreach ($tuples as $tuple) { 69 | if (count(array_diff_assoc($prototype, $tuple)) > 0) { 70 | return false; 71 | } 72 | } 73 | return true; 74 | } 75 | 76 | private function compareOptions(): bool 77 | { 78 | // Collect fields options 79 | $optionsSet = array_map(static function (Field $field): array { 80 | return iterator_to_array($field->getOptions()); 81 | }, $this->fields); 82 | 83 | // Compare options cont 84 | $countResult = array_count_values(array_map('count', $optionsSet)); 85 | if (count($countResult) !== 1) { 86 | return false; 87 | } 88 | 89 | // Compare options content 90 | $prototype = array_shift($optionsSet); 91 | foreach ($optionsSet as $options) { 92 | if (count(array_diff_assoc($prototype, $options)) > 0) { 93 | return false; 94 | } 95 | } 96 | 97 | return true; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Definition/Entity.php: -------------------------------------------------------------------------------- 1 | |null 34 | */ 35 | private ?string $class = null; 36 | 37 | /** 38 | * @var non-empty-string|null 39 | */ 40 | private ?string $database = null; 41 | 42 | /** 43 | * @var non-empty-string|null 44 | */ 45 | private ?string $tableName = null; 46 | 47 | /** 48 | * @var class-string|null 49 | */ 50 | private ?string $mapper = null; 51 | 52 | /** 53 | * @var class-string|null 54 | */ 55 | private ?string $source = null; 56 | 57 | /** 58 | * @var class-string|null 59 | */ 60 | private ?string $scope = null; 61 | 62 | /** 63 | * @var class-string>|null 64 | */ 65 | private ?string $repository = null; 66 | 67 | /** 68 | * @var class-string|class-string[]|non-empty-string|non-empty-string[]|null 69 | */ 70 | private array|string|null $typecast = null; 71 | 72 | private array $schema = []; 73 | private FieldMap $fields; 74 | private RelationMap $relations; 75 | private FieldMap $primaryFields; 76 | private array $schemaModifiers = []; 77 | private ?Inheritance $inheritance = null; 78 | 79 | /** @var class-string|null */ 80 | private ?string $stiParent = null; 81 | 82 | private ForeignKeyMap $foreignKeys; 83 | 84 | public function __construct() 85 | { 86 | $this->options = new OptionMap(); 87 | $this->fields = new FieldMap(); 88 | $this->primaryFields = new FieldMap(); 89 | $this->relations = new RelationMap(); 90 | $this->foreignKeys = new ForeignKeyMap(); 91 | } 92 | 93 | public function getOptions(): OptionMap 94 | { 95 | return $this->options; 96 | } 97 | 98 | /** 99 | * @param non-empty-string $role 100 | */ 101 | public function setRole(string $role): self 102 | { 103 | $this->role = $role; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @return non-empty-string|null 110 | */ 111 | public function getRole(): ?string 112 | { 113 | return $this->role; 114 | } 115 | 116 | /** 117 | * @param class-string $class 118 | */ 119 | public function setClass(string $class): self 120 | { 121 | $this->class = $class; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * @return class-string|null 128 | */ 129 | public function getClass(): ?string 130 | { 131 | return $this->class; 132 | } 133 | 134 | /** 135 | * @param class-string|null $mapper 136 | */ 137 | public function setMapper(?string $mapper): self 138 | { 139 | $this->mapper = $mapper; 140 | 141 | return $this; 142 | } 143 | 144 | /** 145 | * @return class-string|null 146 | */ 147 | public function getMapper(): ?string 148 | { 149 | return $this->normalizeClass($this->mapper); 150 | } 151 | 152 | /** 153 | * @param class-string|null $source 154 | */ 155 | public function setSource(?string $source): self 156 | { 157 | $this->source = $source; 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * @return class-string|null 164 | */ 165 | public function getSource(): ?string 166 | { 167 | return $this->normalizeClass($this->source); 168 | } 169 | 170 | /** 171 | * @param class-string|null $scope 172 | */ 173 | public function setScope(?string $scope): self 174 | { 175 | $this->scope = $scope; 176 | 177 | return $this; 178 | } 179 | 180 | /** 181 | * @return class-string|null 182 | */ 183 | public function getScope(): ?string 184 | { 185 | return $this->normalizeClass($this->scope); 186 | } 187 | 188 | /** 189 | * @param class-string>|null $repository 190 | */ 191 | public function setRepository(?string $repository): self 192 | { 193 | $this->repository = $repository; 194 | 195 | return $this; 196 | } 197 | 198 | /** 199 | * @return class-string>|null 200 | */ 201 | public function getRepository(): ?string 202 | { 203 | return $this->normalizeClass($this->repository); 204 | } 205 | 206 | /** 207 | * @param class-string|class-string[]|non-empty-string|non-empty-string[]|null $typecast 208 | * 209 | * @return $this 210 | */ 211 | public function setTypecast(array|string|null $typecast): self 212 | { 213 | $this->typecast = $typecast; 214 | 215 | return $this; 216 | } 217 | 218 | /** 219 | * @return class-string|class-string[]|non-empty-string|non-empty-string[]|null 220 | */ 221 | public function getTypecast(): array|string|null 222 | { 223 | return $this->typecast; 224 | } 225 | 226 | public function getFields(): FieldMap 227 | { 228 | return $this->fields; 229 | } 230 | 231 | public function getRelations(): RelationMap 232 | { 233 | return $this->relations; 234 | } 235 | 236 | public function getForeignKeys(): ForeignKeyMap 237 | { 238 | return $this->foreignKeys; 239 | } 240 | 241 | public function addSchemaModifier(SchemaModifierInterface $modifier): self 242 | { 243 | $this->schemaModifiers[] = $modifier->withRole($this->role ?? throw new EntityException( 244 | 'Entity must have a `role` to be able to add a modifier.', 245 | )); 246 | 247 | return $this; 248 | } 249 | 250 | /** 251 | * @return \Traversable 252 | */ 253 | public function getSchemaModifiers(): \Traversable 254 | { 255 | yield from $this->schemaModifiers; 256 | } 257 | 258 | public function setSchema(array $schema): self 259 | { 260 | $this->schema = $schema; 261 | 262 | return $this; 263 | } 264 | 265 | public function getSchema(): array 266 | { 267 | return $this->schema; 268 | } 269 | 270 | /** 271 | * Merge entity relations and fields. 272 | */ 273 | public function merge(self $entity): void 274 | { 275 | foreach ($entity->getRelations() as $name => $relation) { 276 | if (!$this->relations->has($name)) { 277 | $this->relations->set($name, $relation); 278 | } 279 | } 280 | 281 | foreach ($entity->getFields() as $name => $field) { 282 | if (!$this->fields->has($name)) { 283 | $this->fields->set($name, $field); 284 | } 285 | } 286 | 287 | foreach ($entity->getForeignKeys() as $foreignKey) { 288 | if (!$this->foreignKeys->has($foreignKey)) { 289 | $this->foreignKeys->set($foreignKey); 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * Check if entity has primary key 296 | */ 297 | public function hasPrimaryKey(): bool 298 | { 299 | if ($this->primaryFields->count() > 0) { 300 | return true; 301 | } 302 | 303 | foreach ($this->getFields() as $field) { 304 | if ($field->isPrimary()) { 305 | return true; 306 | } 307 | } 308 | 309 | return false; 310 | } 311 | 312 | /** 313 | * Set primary key using column list 314 | * 315 | * @param string[] $columns 316 | */ 317 | public function setPrimaryColumns(array $columns): void 318 | { 319 | $this->primaryFields = new FieldMap(); 320 | 321 | foreach ($columns as $column) { 322 | $name = $this->fields->getKeyByColumnName($column); 323 | $this->primaryFields->set($name, $this->fields->get($name)); 324 | } 325 | } 326 | 327 | /** 328 | * Get entity primary key property names 329 | */ 330 | public function getPrimaryFields(): FieldMap 331 | { 332 | $map = new FieldMap(); 333 | 334 | foreach ($this->getFields() as $name => $field) { 335 | if ($field->isPrimary()) { 336 | $map->set($name, $field); 337 | } 338 | } 339 | 340 | if ($this->primaryFields->count() === 0 xor $map->count() === 0) { 341 | return $map->count() === 0 ? $this->primaryFields : $map; 342 | } 343 | 344 | if ( 345 | $this->primaryFields->count() !== $map->count() 346 | || array_diff($map->getColumnNames(), $this->primaryFields->getColumnNames()) !== [] 347 | ) { 348 | // todo make friendly exception 349 | throw new EntityException("Ambiguous primary key definition for `{$this->getRole()}`."); 350 | } 351 | 352 | return $this->primaryFields; 353 | } 354 | 355 | public function setInheritance(Inheritance $inheritance): void 356 | { 357 | $this->inheritance = $inheritance; 358 | } 359 | 360 | public function getInheritance(): ?Inheritance 361 | { 362 | return $this->inheritance; 363 | } 364 | 365 | /** 366 | * Check if entity is a child of STI 367 | */ 368 | public function isChildOfSingleTableInheritance(): bool 369 | { 370 | return $this->stiParent !== null; 371 | } 372 | 373 | /** 374 | * @param class-string|null $parentClass 375 | */ 376 | public function markAsChildOfSingleTableInheritance(?string $parentClass): void 377 | { 378 | $this->stiParent = $parentClass; 379 | } 380 | 381 | public function getDatabase(): ?string 382 | { 383 | return $this->database; 384 | } 385 | 386 | /** 387 | * @param non-empty-string|null $database 388 | */ 389 | public function setDatabase(?string $database): void 390 | { 391 | $this->database = $database; 392 | } 393 | 394 | public function getTableName(): ?string 395 | { 396 | return $this->tableName; 397 | } 398 | 399 | /** 400 | * @param non-empty-string $tableName 401 | */ 402 | public function setTableName(string $tableName): void 403 | { 404 | $this->tableName = $tableName; 405 | } 406 | 407 | /** 408 | * Full entity copy. 409 | */ 410 | public function __clone() 411 | { 412 | $this->options = clone $this->options; 413 | $this->fields = clone $this->fields; 414 | $this->primaryFields = clone $this->primaryFields; 415 | $this->relations = clone $this->relations; 416 | $this->foreignKeys = clone $this->foreignKeys; 417 | } 418 | 419 | /** 420 | * @template T of object 421 | * 422 | * @param class-string|null $class 423 | * 424 | * @return ($class is class-string ? class-string : null) 425 | */ 426 | private function normalizeClass(?string $class = null): ?string 427 | { 428 | if ($class === null) { 429 | return null; 430 | } 431 | 432 | /** @var class-string $class */ 433 | $class = \ltrim($class, '\\'); 434 | 435 | return $class; 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /src/Definition/Field.php: -------------------------------------------------------------------------------- 1 | options = new OptionMap(); 43 | $this->attributes = new OptionMap(); 44 | } 45 | 46 | public function getOptions(): OptionMap 47 | { 48 | return $this->options; 49 | } 50 | 51 | public function getAttributes(): OptionMap 52 | { 53 | return $this->attributes; 54 | } 55 | 56 | /** 57 | * @return non-empty-string 58 | */ 59 | public function getType(): string 60 | { 61 | if (empty($this->type)) { 62 | throw new FieldException('Field type must be set'); 63 | } 64 | 65 | return $this->type; 66 | } 67 | 68 | /** 69 | * @param non-empty-string $type 70 | */ 71 | public function setType(string $type): self 72 | { 73 | $this->type = $type; 74 | 75 | return $this; 76 | } 77 | 78 | public function setPrimary(bool $primary): self 79 | { 80 | $this->primary = $primary; 81 | 82 | return $this; 83 | } 84 | 85 | public function isPrimary(): bool 86 | { 87 | return $this->primary || in_array($this->type, ['primary', 'bigPrimary', 'smallPrimary']); 88 | } 89 | 90 | /** 91 | * @param non-empty-string $column 92 | */ 93 | public function setColumn(string $column): self 94 | { 95 | $this->column = $column; 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @return non-empty-string 102 | * @throws FieldException 103 | * 104 | */ 105 | public function getColumn(): string 106 | { 107 | if (empty($this->column)) { 108 | throw new FieldException('Column mapping must be set'); 109 | } 110 | 111 | return $this->column; 112 | } 113 | 114 | /** 115 | * @param callable-array|string|null $typecast 116 | */ 117 | public function setTypecast(array|string|null $typecast): self 118 | { 119 | $this->typecast = $typecast; 120 | 121 | return $this; 122 | } 123 | 124 | public function hasTypecast(): bool 125 | { 126 | return $this->typecast !== null; 127 | } 128 | 129 | /** 130 | * @return callable-array|string|null 131 | */ 132 | public function getTypecast(): array|string|null 133 | { 134 | return $this->typecast; 135 | } 136 | 137 | /** 138 | * @param int|null $type Generating type {@see GeneratedField*} constants. 139 | */ 140 | public function setGenerated(int|null $type): self 141 | { 142 | $this->generated = $type; 143 | 144 | return $this; 145 | } 146 | 147 | public function getGenerated(): ?int 148 | { 149 | return $this->generated; 150 | } 151 | 152 | public function setReferenced(bool $indexed): self 153 | { 154 | $this->referenced = $indexed; 155 | 156 | return $this; 157 | } 158 | 159 | public function isReferenced(): bool 160 | { 161 | return $this->referenced; 162 | } 163 | 164 | public function getEntityClass(): ?string 165 | { 166 | return $this->entityClass; 167 | } 168 | 169 | public function setEntityClass(?string $entityClass): self 170 | { 171 | $this->entityClass = $entityClass; 172 | 173 | return $this; 174 | } 175 | 176 | public function __clone() 177 | { 178 | $this->options = clone $this->options; 179 | $this->attributes = clone $this->attributes; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Definition/ForeignKey.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private array $innerColumns; 18 | 19 | /** 20 | * @var array 21 | */ 22 | private array $outerColumns; 23 | 24 | private bool $createIndex; 25 | 26 | /** 27 | * @var non-empty-string 28 | */ 29 | private string $action; 30 | 31 | /** 32 | * @param non-empty-string $target 33 | */ 34 | public function setTarget(string $target): self 35 | { 36 | $this->target = $target; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * @return non-empty-string 43 | */ 44 | public function getTarget(): string 45 | { 46 | return $this->target; 47 | } 48 | 49 | /** 50 | * @param array $columns 51 | */ 52 | public function setInnerColumns(array $columns): self 53 | { 54 | $this->innerColumns = $columns; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * @return array 61 | */ 62 | public function getInnerColumns(): array 63 | { 64 | return $this->innerColumns; 65 | } 66 | 67 | /** 68 | * @param array $columns 69 | */ 70 | public function setOuterColumns(array $columns): self 71 | { 72 | $this->outerColumns = $columns; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * @return array 79 | */ 80 | public function getOuterColumns(): array 81 | { 82 | return $this->outerColumns; 83 | } 84 | 85 | /** 86 | * Create an index on innerKey. 87 | */ 88 | public function createIndex(bool $createIndex = true): self 89 | { 90 | $this->createIndex = $createIndex; 91 | 92 | return $this; 93 | } 94 | 95 | public function isCreateIndex(): bool 96 | { 97 | return $this->createIndex; 98 | } 99 | 100 | /** 101 | * @param non-empty-string $action 102 | */ 103 | public function setAction(string $action): self 104 | { 105 | $this->action = $action; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @return non-empty-string 112 | */ 113 | public function getAction(): string 114 | { 115 | return $this->action; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Definition/Inheritance.php: -------------------------------------------------------------------------------- 1 | outerKey; 20 | } 21 | 22 | public function getParent(): Entity 23 | { 24 | return $this->parent; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Definition/Inheritance/SingleTable.php: -------------------------------------------------------------------------------- 1 | */ 12 | private array $children = []; 13 | 14 | private ?string $discriminator = null; 15 | 16 | /** 17 | * @param non-empty-string $discriminatorValue 18 | * @param class-string $class 19 | */ 20 | public function addChild(string $discriminatorValue, string $class): void 21 | { 22 | $this->children[$discriminatorValue] = $class; 23 | } 24 | 25 | public function getChildren(): array 26 | { 27 | return $this->children; 28 | } 29 | 30 | public function getDiscriminator(): ?string 31 | { 32 | return $this->discriminator; 33 | } 34 | 35 | /** 36 | * @param non-empty-string|null $discriminator 37 | */ 38 | public function setDiscriminator(?string $discriminator): void 39 | { 40 | $this->discriminator = $discriminator; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Definition/Map/FieldMap.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class FieldMap implements \IteratorAggregate, \Countable 16 | { 17 | /** @var Field[] */ 18 | private $fields = []; 19 | 20 | public function count(): int 21 | { 22 | return count($this->fields); 23 | } 24 | 25 | /** 26 | * Get field column names 27 | */ 28 | public function getColumnNames(): array 29 | { 30 | return array_values(array_map(static function (Field $field) { 31 | return $field->getColumn(); 32 | }, $this->fields)); 33 | } 34 | 35 | /** 36 | * Get property names 37 | */ 38 | public function getNames(): array 39 | { 40 | return array_keys($this->fields); 41 | } 42 | 43 | public function has(string $name): bool 44 | { 45 | return isset($this->fields[$name]); 46 | } 47 | 48 | /** 49 | * Check if field with given column name exist 50 | */ 51 | public function hasColumn(string $name): bool 52 | { 53 | foreach ($this->fields as $field) { 54 | if ($field->getColumn() === $name) { 55 | return true; 56 | } 57 | } 58 | 59 | return false; 60 | } 61 | 62 | /** 63 | * Get field by property name 64 | */ 65 | public function get(string $name): Field 66 | { 67 | if (!$this->has($name)) { 68 | throw new FieldException("Undefined field `{$name}`."); 69 | } 70 | 71 | return $this->fields[$name]; 72 | } 73 | 74 | /** 75 | * Get property name by column name 76 | */ 77 | public function getKeyByColumnName(string $name): string 78 | { 79 | foreach ($this->fields as $key => $field) { 80 | if ($field->getColumn() === $name) { 81 | return $key; 82 | } 83 | } 84 | 85 | throw new FieldException("Undefined field with column name `{$name}`."); 86 | } 87 | 88 | /** 89 | * Get field by column name 90 | */ 91 | public function getByColumnName(string $name): Field 92 | { 93 | foreach ($this->fields as $field) { 94 | if ($field->getColumn() === $name) { 95 | return $field; 96 | } 97 | } 98 | 99 | throw new FieldException("Undefined field with column name `{$name}`."); 100 | } 101 | 102 | public function set(string $name, Field $field): self 103 | { 104 | if ($this->has($name)) { 105 | throw new FieldException("Field `{$name}` already exists."); 106 | } 107 | 108 | $this->fields[$name] = $field; 109 | 110 | return $this; 111 | } 112 | 113 | public function remove(string $name): self 114 | { 115 | unset($this->fields[$name]); 116 | return $this; 117 | } 118 | 119 | public function getIterator(): \Traversable 120 | { 121 | return new \ArrayIterator($this->fields); 122 | } 123 | 124 | /** 125 | * Cloning. 126 | */ 127 | public function __clone() 128 | { 129 | foreach ($this->fields as $name => $field) { 130 | $this->fields[$name] = clone $field; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Definition/Map/ForeignKeyMap.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class ForeignKeyMap implements \IteratorAggregate, \Countable 15 | { 16 | /** 17 | * @var array 18 | */ 19 | private array $foreignKeys = []; 20 | 21 | public function has(ForeignKey $foreignKey): bool 22 | { 23 | return isset($this->foreignKeys[$this->generateIdentifier($foreignKey)]); 24 | } 25 | 26 | public function set(ForeignKey $foreignKey): self 27 | { 28 | $this->foreignKeys[$this->generateIdentifier($foreignKey)] = $foreignKey; 29 | 30 | return $this; 31 | } 32 | 33 | public function remove(ForeignKey $foreignKey): self 34 | { 35 | unset($this->foreignKeys[$this->generateIdentifier($foreignKey)]); 36 | 37 | return $this; 38 | } 39 | 40 | public function count(): int 41 | { 42 | return \count($this->foreignKeys); 43 | } 44 | 45 | public function getIterator(): \Traversable 46 | { 47 | return new \ArrayIterator($this->foreignKeys); 48 | } 49 | 50 | /** 51 | * Cloning. 52 | */ 53 | public function __clone() 54 | { 55 | foreach ($this->foreignKeys as $index => $foreignKey) { 56 | $this->foreignKeys[$index] = clone $foreignKey; 57 | } 58 | } 59 | 60 | /** 61 | * @return non-empty-string 62 | */ 63 | private function generateIdentifier(ForeignKey $foreignKey): string 64 | { 65 | return \sprintf( 66 | '%s:%s:%s', 67 | $foreignKey->getTarget(), 68 | \implode(',', $foreignKey->getInnerColumns()), 69 | \implode(',', $foreignKey->getOuterColumns()), 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Definition/Map/OptionMap.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class OptionMap implements \IteratorAggregate 13 | { 14 | private array $options = []; 15 | 16 | public function has(string $name): bool 17 | { 18 | return array_key_exists($name, $this->options); 19 | } 20 | 21 | /** 22 | * @throws OptionException 23 | */ 24 | public function get(string $name): mixed 25 | { 26 | if (!$this->has($name)) { 27 | throw new OptionException("Undefined option `{$name}`"); 28 | } 29 | 30 | return $this->options[$name]; 31 | } 32 | 33 | public function set(string $name, mixed $value): self 34 | { 35 | $this->options[$name] = $value; 36 | 37 | return $this; 38 | } 39 | 40 | public function remove(string $name): self 41 | { 42 | unset($this->options[$name]); 43 | 44 | return $this; 45 | } 46 | 47 | public function getIterator(): \ArrayIterator 48 | { 49 | return new \ArrayIterator($this->options); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Definition/Map/RelationMap.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class RelationMap implements \IteratorAggregate 14 | { 15 | /** @var array */ 16 | private array $relations = []; 17 | 18 | public function has(string $name): bool 19 | { 20 | return isset($this->relations[$name]); 21 | } 22 | 23 | public function get(string $name): Relation 24 | { 25 | if (!$this->has($name)) { 26 | throw new RelationException("Undefined relation `{$name}`"); 27 | } 28 | 29 | return $this->relations[$name]; 30 | } 31 | 32 | public function set(string $name, Relation $relation): self 33 | { 34 | if ($this->has($name)) { 35 | throw new RelationException("Relation `{$name}` already exists"); 36 | } 37 | 38 | $this->relations[$name] = $relation; 39 | 40 | return $this; 41 | } 42 | 43 | public function remove(string $name): self 44 | { 45 | unset($this->relations[$name]); 46 | return $this; 47 | } 48 | 49 | /** 50 | * @return \Traversable 51 | */ 52 | public function getIterator(): \Traversable 53 | { 54 | return new \ArrayIterator($this->relations); 55 | } 56 | 57 | public function __clone() 58 | { 59 | foreach ($this->relations as $name => $relation) { 60 | $this->relations[$name] = clone $relation; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Definition/Relation.php: -------------------------------------------------------------------------------- 1 | options = new OptionMap(); 36 | } 37 | 38 | public function getOptions(): OptionMap 39 | { 40 | return $this->options; 41 | } 42 | 43 | public function setType(string $type): self 44 | { 45 | $this->type = $type; 46 | 47 | return $this; 48 | } 49 | 50 | public function getType(): string 51 | { 52 | if ($this->type === null) { 53 | throw new RelationException('Relation type must be set'); 54 | } 55 | 56 | return $this->type; 57 | } 58 | 59 | /** 60 | * @param non-empty-string $target 61 | * 62 | */ 63 | public function setTarget(string $target): self 64 | { 65 | $this->target = $target; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * @return non-empty-string 72 | */ 73 | public function getTarget(): string 74 | { 75 | if ($this->target === null) { 76 | throw new RelationException('Relation target must be set'); 77 | } 78 | 79 | return $this->target; 80 | } 81 | 82 | public function setInverse(string $into, string $as, ?int $load = null): self 83 | { 84 | $this->inverse = $into; 85 | $this->inverseType = $as; 86 | $this->inverseLoad = $load; 87 | 88 | return $this; 89 | } 90 | 91 | public function isInversed(): bool 92 | { 93 | return $this->inverse !== null; 94 | } 95 | 96 | public function getInverseName(): ?string 97 | { 98 | return $this->inverse; 99 | } 100 | 101 | public function getInverseType(): ?string 102 | { 103 | return $this->inverseType; 104 | } 105 | 106 | public function getInverseLoad(): ?int 107 | { 108 | return $this->inverseLoad; 109 | } 110 | 111 | /** 112 | * Cloning. 113 | */ 114 | public function __clone() 115 | { 116 | $this->options = clone $this->options; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Exception/ColumnException.php: -------------------------------------------------------------------------------- 1 | getRole()}` has conflicted field `{$fieldName}`."); 16 | } 17 | 18 | public function getName(): string 19 | { 20 | return 'Embedded entity primary key collision'; 21 | } 22 | 23 | public function getSolution(): ?string 24 | { 25 | return "The primary key of the composite entity must be projected onto the embedded entity.\n" 26 | . "However, the embedded entity already has a field with the same name.\n\n" 27 | . "Possible solutions:\n" 28 | . "- If the conflicting field applies only to an embedded entity, then rename it.\n" 29 | . '- If you want to receive the primary key value of a composite entity in this field,' 30 | . ' then remove its definition from the column list in the schema.'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exception/OptionException.php: -------------------------------------------------------------------------------- 1 | entity->getRole() ?? $this->entity->getClass()), 18 | )); 19 | } 20 | 21 | public function getName(): string 22 | { 23 | return 'Discriminator column is not present.'; 24 | } 25 | 26 | public function getSolution(): ?string 27 | { 28 | $fields = implode('`, `', $this->entity->getFields()->getNames()); 29 | 30 | return sprintf( 31 | "Discriminator column is required for Single Table Inheritance schema.\n" . 32 | 'You have to specify one of the defined fields of the `%s` role: `%s`', 33 | (string) ($this->entity->getRole() ?? $this->entity->getClass()), 34 | $fields, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/TableInheritance/WrongDiscriminatorColumnException.php: -------------------------------------------------------------------------------- 1 | entity->getRole() ?? $this->entity->getClass()), 19 | )); 20 | } 21 | 22 | public function getName(): string 23 | { 24 | return 'Discriminator column is not found among the entity fields.'; 25 | } 26 | 27 | public function getSolution(): ?string 28 | { 29 | $fields = implode('`, `', $this->entity->getFields()->getNames()); 30 | 31 | return sprintf( 32 | 'You have to specify one of the defined fields of the `%s` role: `%s`', 33 | (string) ($this->entity->getRole() ?? $this->entity->getClass()), 34 | $fields, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/TableInheritance/WrongParentKeyColumnException.php: -------------------------------------------------------------------------------- 1 | entity->getRole() ?? $this->entity->getClass()), 19 | )); 20 | } 21 | 22 | public function getName(): string 23 | { 24 | return 'Outer key column is not found among parent entity fields.'; 25 | } 26 | 27 | public function getSolution(): ?string 28 | { 29 | $fields = implode('`, `', $this->entity->getFields()->getNames()); 30 | 31 | return sprintf( 32 | 'You have to specify one of the defined fields of the `%s` role: `%s`', 33 | (string) ($this->entity->getRole() ?? $this->entity->getClass()), 34 | $fields, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/TableInheritanceException.php: -------------------------------------------------------------------------------- 1 | getForeignKeys() as $fk) { 16 | $target = $registry->getEntity($fk->getTarget()); 17 | $targetSchema = $registry->getTableSchema($target); 18 | 19 | $pkExists = \array_diff($fk->getOuterColumns(), $targetSchema->getPrimaryKeys()) === []; 20 | if (!$pkExists && !$targetSchema->hasIndex($fk->getOuterColumns())) { 21 | $targetSchema->index($fk->getOuterColumns())->unique(); 22 | } 23 | 24 | $registry->getTableSchema($entity) 25 | ->foreignKey($fk->getInnerColumns(), $fk->isCreateIndex()) 26 | ->references($registry->getTable($target), $fk->getOuterColumns()) 27 | ->onUpdate($fk->getAction()) 28 | ->onDelete($fk->getAction()); 29 | } 30 | } 31 | 32 | return $registry; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Generator/GenerateModifiers.php: -------------------------------------------------------------------------------- 1 | register($registry, $entity); 23 | } 24 | 25 | return $registry; 26 | } 27 | 28 | protected function register(Registry $registry, Entity $entity): void 29 | { 30 | $role = $entity->getRole(); 31 | assert($role !== null); 32 | foreach ($entity->getSchemaModifiers() as $modifier) { 33 | \assert($modifier instanceof SchemaModifierInterface); 34 | try { 35 | $modifier->compute($registry); 36 | } catch (SchemaModifierException $e) { 37 | throw new SchemaException( 38 | sprintf('Unable to compute modifier `%s` for the `%s` role.', $modifier::class, $role), 39 | $e->getCode(), 40 | $e, 41 | ); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Generator/GenerateRelations.php: -------------------------------------------------------------------------------- 1 | Relation::NULLABLE, 28 | 'cascade' => Relation::CASCADE, 29 | 'load' => Relation::LOAD, 30 | 'innerKey' => Relation::INNER_KEY, 31 | 'outerKey' => Relation::OUTER_KEY, 32 | 'morphKey' => Relation::MORPH_KEY, 33 | 'through' => Relation::THROUGH_ENTITY, 34 | 'throughInnerKey' => Relation::THROUGH_INNER_KEY, 35 | 'throughOuterKey' => Relation::THROUGH_OUTER_KEY, 36 | 'throughWhere' => Relation::THROUGH_WHERE, 37 | 'where' => Relation::WHERE, 38 | 'collection' => Relation::COLLECTION_TYPE, 39 | 'orderBy' => Relation::ORDER_BY, 40 | 'fkCreate' => RelationSchema::FK_CREATE, 41 | 'fkAction' => RelationSchema::FK_ACTION, 42 | 'fkOnDelete' => RelationSchema::FK_ON_DELETE, 43 | 'indexCreate' => RelationSchema::INDEX_CREATE, 44 | 'morphKeyLength' => RelationSchema::MORPH_KEY_LENGTH, 45 | 'embeddedPrefix' => RelationSchema::EMBEDDED_PREFIX, 46 | 47 | // deprecated 48 | 'though' => Relation::THROUGH_ENTITY, 49 | 'thoughInnerKey' => Relation::THROUGH_INNER_KEY, 50 | 'thoughOuterKey' => Relation::THROUGH_OUTER_KEY, 51 | 'thoughWhere' => Relation::THROUGH_WHERE, 52 | ]; 53 | 54 | /** @var OptionSchema */ 55 | private $options; 56 | 57 | /** @var RelationInterface[] */ 58 | private $relations = []; 59 | 60 | public function __construct(?array $relations = null, ?OptionSchema $optionSchema = null) 61 | { 62 | $relations = $relations ?? self::getDefaultRelations(); 63 | $this->options = $optionSchema ?? new OptionSchema(self::OPTION_MAP); 64 | 65 | foreach ($relations as $id => $relation) { 66 | if (!$relation instanceof RelationInterface) { 67 | throw new \InvalidArgumentException( 68 | sprintf( 69 | 'Invalid relation type, RelationInterface excepted, `%s` given', 70 | is_object($relation) ? get_class($relation) : gettype($relation), 71 | ), 72 | ); 73 | } 74 | 75 | $this->relations[$id] = $relation; 76 | } 77 | } 78 | 79 | public function run(Registry $registry): Registry 80 | { 81 | foreach ($registry as $entity) { 82 | $this->register($registry, $entity); 83 | } 84 | 85 | foreach ($registry as $entity) { 86 | $this->inverse($registry, $entity); 87 | } 88 | 89 | return $registry; 90 | } 91 | 92 | protected static function getDefaultRelations(): array 93 | { 94 | return [ 95 | 'embedded' => new Definition\Embedded(), 96 | 'belongsTo' => new Definition\BelongsTo(), 97 | 'hasOne' => new Definition\HasOne(), 98 | 'hasMany' => new Definition\HasMany(), 99 | 'refersTo' => new Definition\RefersTo(), 100 | 'manyToMany' => new Definition\ManyToMany(), 101 | 'belongsToMorphed' => new Definition\Morphed\BelongsToMorphed(), 102 | 'morphedHasOne' => new Definition\Morphed\MorphedHasOne(), 103 | 'morphedHasMany' => new Definition\Morphed\MorphedHasMany(), 104 | ]; 105 | } 106 | 107 | protected function register(Registry $registry, Entity $entity): void 108 | { 109 | $role = $entity->getRole(); 110 | \assert($role !== null); 111 | 112 | foreach ($entity->getRelations() as $name => $r) { 113 | $schema = $this->initRelation($r->getType())->withContext( 114 | $name, 115 | $role, 116 | $r->getTarget(), 117 | $this->options->withOptions($r->getOptions()), 118 | ); 119 | 120 | // compute relation values (field names, related entities and etc) 121 | try { 122 | $schema->compute($registry); 123 | } catch (RelationException $e) { 124 | throw new SchemaException( 125 | "Unable to compute relation `{$role}`.`{$name}`", 126 | $e->getCode(), 127 | $e, 128 | ); 129 | } 130 | 131 | $registry->registerRelation($entity, $name, $schema); 132 | } 133 | } 134 | 135 | protected function inverse(Registry $registry, Entity $entity): void 136 | { 137 | foreach ($entity->getRelations() as $name => $r) { 138 | if (!$r->isInversed()) { 139 | continue; 140 | } 141 | 142 | $inverseName = $r->getInverseName(); 143 | $inverseType = $r->getInverseType(); 144 | \assert(!empty($inverseName) && !empty($inverseType)); 145 | 146 | $schema = $registry->getRelation($entity, $name); 147 | if (!$schema instanceof InversableInterface) { 148 | throw new SchemaException('Unable to inverse relation of type ' . get_class($schema)); 149 | } 150 | 151 | foreach ($schema->inverseTargets($registry) as $target) { 152 | try { 153 | $inversed = $schema->inverseRelation( 154 | $this->initRelation($inverseType), 155 | $inverseName, 156 | $r->getInverseLoad(), 157 | ); 158 | 159 | $registry->registerRelation($target, $inverseName, $inversed); 160 | } catch (RelationException $e) { 161 | throw new SchemaException( 162 | "Unable to inverse relation `{$entity->getRole()}`.`{$name}`", 163 | $e->getCode(), 164 | $e, 165 | ); 166 | } 167 | } 168 | } 169 | } 170 | 171 | protected function initRelation(string $type): RelationInterface 172 | { 173 | if (!isset($this->relations[$type])) { 174 | throw new RegistryException("Undefined relation type `{$type}`"); 175 | } 176 | 177 | return $this->relations[$type]; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Generator/GenerateTypecast.php: -------------------------------------------------------------------------------- 1 | compute($registry, $entity); 21 | } 22 | 23 | return $registry; 24 | } 25 | 26 | /** 27 | * Automatically clarify column types based on table column types. 28 | * 29 | */ 30 | protected function compute(Registry $registry, Entity $entity): void 31 | { 32 | if (!$registry->hasTable($entity)) { 33 | return; 34 | } 35 | 36 | $table = $registry->getTableSchema($entity); 37 | 38 | foreach ($entity->getFields() as $field) { 39 | if ($field->hasTypecast() || !$table->hasColumn($field->getColumn())) { 40 | continue; 41 | } 42 | 43 | $column = $table->column($field->getColumn()); 44 | 45 | $field->setTypecast($this->typecast($column)); 46 | } 47 | } 48 | 49 | /** 50 | * 51 | * @return callable-array|string|null 52 | */ 53 | private function typecast(AbstractColumn $column) 54 | { 55 | switch ($column->getType()) { 56 | case AbstractColumn::BOOL: 57 | return 'bool'; 58 | case AbstractColumn::INT: 59 | return 'int'; 60 | case AbstractColumn::FLOAT: 61 | return 'float'; 62 | } 63 | 64 | if (in_array($column->getAbstractType(), ['datetime', 'date', 'time', 'timestamp'])) { 65 | return 'datetime'; 66 | } 67 | 68 | if ($column->getType() === AbstractColumn::STRING) { 69 | return 'string'; 70 | } 71 | 72 | return null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Generator/PrintChanges.php: -------------------------------------------------------------------------------- 1 | changes = []; 24 | foreach ($registry->getIterator() as $e) { 25 | if ($registry->hasTable($e)) { 26 | $table = $registry->getTableSchema($e); 27 | 28 | if ($this->hasTableChanges($table)) { 29 | $key = $registry->getDatabase($e) . ':' . $registry->getTable($e); 30 | $this->changes[$key] = [ 31 | 'database' => $registry->getDatabase($e), 32 | 'table' => $registry->getTable($e), 33 | 'schema' => $table, 34 | ]; 35 | } 36 | } 37 | } 38 | 39 | if (!$this->hasChanges()) { 40 | $this->output->writeln('No database changes has been detected'); 41 | 42 | return $registry; 43 | } 44 | 45 | $this->output->writeln('Schema changes:'); 46 | 47 | foreach ($this->changes as $change) { 48 | $this->output->write(\sprintf('• %s.%s', $change['database'], $change['table'])); 49 | $this->describeChanges($change['schema']); 50 | } 51 | 52 | return $registry; 53 | } 54 | 55 | public function hasChanges(): bool 56 | { 57 | return $this->changes !== []; 58 | } 59 | 60 | private function describeChanges(AbstractTable $table): void 61 | { 62 | if ($table->getStatus() === AbstractTable::STATUS_DECLARED_DROPPED) { 63 | $this->output->writeln(' - drop table'); 64 | return; 65 | } 66 | 67 | if (!$this->output->isVerbose()) { 68 | $this->output->writeln( 69 | \sprintf( 70 | ': %s change(s) detected', 71 | $this->numChanges($table), 72 | ), 73 | ); 74 | 75 | return; 76 | } 77 | 78 | $this->output->write("\n"); 79 | 80 | if (!$table->exists()) { 81 | $this->output->writeln(' - create table'); 82 | } 83 | 84 | $cmp = $table->getComparator(); 85 | 86 | $this->describeColumns($cmp); 87 | $this->describeIndexes($cmp); 88 | $this->describeFKs($cmp); 89 | } 90 | 91 | private function describeColumns(ComparatorInterface $cmp): void 92 | { 93 | foreach ($cmp->addedColumns() as $column) { 94 | $this->output->writeln(" - add column [{$column->getName()}]"); 95 | } 96 | 97 | foreach ($cmp->droppedColumns() as $column) { 98 | $this->output->writeln(" - drop column [{$column->getName()}]"); 99 | } 100 | 101 | foreach ($cmp->alteredColumns() as $column) { 102 | $column = $column[0]; 103 | $this->output->writeln(" - alter column [{$column->getName()}]"); 104 | } 105 | } 106 | 107 | private function describeIndexes(ComparatorInterface $cmp): void 108 | { 109 | foreach ($cmp->addedIndexes() as $index) { 110 | $index = \implode(', ', $index->getColumns()); 111 | $this->output->writeln(" - add index on [{$index}]"); 112 | } 113 | 114 | foreach ($cmp->droppedIndexes() as $index) { 115 | $index = \implode(', ', $index->getColumns()); 116 | $this->output->writeln(" - drop index on [{$index}]"); 117 | } 118 | 119 | foreach ($cmp->alteredIndexes() as $index) { 120 | $index = $index[0]; 121 | $index = \implode(', ', $index->getColumns()); 122 | $this->output->writeln(" - alter index on [{$index}]"); 123 | } 124 | } 125 | 126 | private function describeFKs(ComparatorInterface $cmp): void 127 | { 128 | foreach ($cmp->addedForeignKeys() as $fk) { 129 | $fkColumns = \implode(', ', $fk->getColumns()); 130 | $this->output->writeln(" - add foreign key on [{$fkColumns}]"); 131 | } 132 | 133 | foreach ($cmp->droppedForeignKeys() as $fk) { 134 | $fkColumns = \implode(', ', $fk->getColumns()); 135 | $this->output->writeln(" - drop foreign key on [{$fkColumns}]"); 136 | } 137 | 138 | foreach ($cmp->alteredForeignKeys() as $fk) { 139 | $fk = $fk[0]; 140 | $fkColumns = \implode(', ', $fk->getColumns()); 141 | $this->output->writeln(" - alter foreign key on [{$fkColumns}]"); 142 | } 143 | } 144 | 145 | private function numChanges(AbstractTable $table): int 146 | { 147 | $cmp = $table->getComparator(); 148 | 149 | return \count($cmp->addedColumns()) 150 | + \count($cmp->droppedColumns()) 151 | + \count($cmp->alteredColumns()) 152 | + \count($cmp->addedIndexes()) 153 | + \count($cmp->droppedIndexes()) 154 | + \count($cmp->alteredIndexes()) 155 | + \count($cmp->addedForeignKeys()) 156 | + \count($cmp->droppedForeignKeys()) 157 | + \count($cmp->alteredForeignKeys()); 158 | } 159 | 160 | private function hasTableChanges(AbstractTable $table): bool 161 | { 162 | return $table->getComparator()->hasChanges() 163 | || !$table->exists() 164 | || $table->getStatus() === AbstractTable::STATUS_DECLARED_DROPPED; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Generator/RenderModifiers.php: -------------------------------------------------------------------------------- 1 | register($registry, $entity); 23 | } 24 | 25 | return $registry; 26 | } 27 | 28 | protected function register(Registry $registry, Entity $entity): void 29 | { 30 | $role = $entity->getRole(); 31 | assert($role !== null); 32 | foreach ($entity->getSchemaModifiers() as $modifier) { 33 | \assert($modifier instanceof SchemaModifierInterface); 34 | try { 35 | $modifier->render($registry); 36 | } catch (SchemaModifierException $e) { 37 | throw new SchemaException( 38 | sprintf('Unable to render modifier `%s` for the `%s` role.', $modifier::class, $role), 39 | $e->getCode(), 40 | $e, 41 | ); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Generator/RenderRelations.php: -------------------------------------------------------------------------------- 1 | compute($registry, $entity); 20 | } 21 | 22 | return $registry; 23 | } 24 | 25 | protected function compute(Registry $registry, Entity $entity): void 26 | { 27 | foreach ($registry->getRelations($entity) as $relation) { 28 | $relation->render($registry); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Generator/RenderTables.php: -------------------------------------------------------------------------------- 1 | reflector = new Reflector(); 27 | } 28 | 29 | public function run(Registry $registry): Registry 30 | { 31 | foreach ($registry as $entity) { 32 | $this->compute($registry, $entity); 33 | } 34 | 35 | return $registry; 36 | } 37 | 38 | /** 39 | * List of all involved tables sorted in order of their dependency. 40 | * 41 | * @return AbstractTable[] 42 | */ 43 | public function getTables(): array 44 | { 45 | return $this->reflector->sortedTables(); 46 | } 47 | 48 | public function getReflector(): Reflector 49 | { 50 | return $this->reflector; 51 | } 52 | 53 | /** 54 | * Generate table schema based on given entity definition. 55 | */ 56 | private function compute(Registry $registry, Entity $entity): void 57 | { 58 | if (!$registry->hasTable($entity)) { 59 | // do not render entities without associated table 60 | return; 61 | } 62 | 63 | $table = $registry->getTableSchema($entity); 64 | 65 | $primaryKeys = []; 66 | foreach ($entity->getFields() as $field) { 67 | $column = Column::parse($field); 68 | 69 | if ($column->isPrimary()) { 70 | $primaryKeys[] = $field->getColumn(); 71 | } 72 | 73 | $column->render($table->column($field->getColumn())); 74 | } 75 | 76 | // todo fix discriminator column name 77 | // if ($registry->getChildren($entity) !== []) { 78 | // if (!$table->hasColumn(Mapper::ENTITY_TYPE)) { 79 | // $table->string(Mapper::ENTITY_TYPE, 32); 80 | // } 81 | // } 82 | 83 | if (count($primaryKeys)) { 84 | $table->setPrimaryKeys($primaryKeys); 85 | } 86 | 87 | $this->reflector->addTable($table); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Generator/ResetTables.php: -------------------------------------------------------------------------------- 1 | hasTable($entity)) { 19 | continue; 20 | } 21 | 22 | $schema = $registry->getTableSchema($entity); 23 | if ($schema->exists()) { 24 | $state = $schema->getState(); 25 | 26 | // clean up all indexes and columns 27 | foreach ($state->getForeignKeys() as $fk) { 28 | $state->forgerForeignKey($fk); 29 | } 30 | 31 | // clean up all indexes and columns 32 | foreach ($state->getColumns() as $column) { 33 | $state->forgetColumn($column); 34 | } 35 | 36 | foreach ($state->getIndexes() as $index) { 37 | $state->forgetIndex($index); 38 | } 39 | 40 | $state->setPrimaryKeys([]); 41 | 42 | $schema->setState($state); 43 | } 44 | } 45 | 46 | return $registry; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Generator/ResolveInterfaces.php: -------------------------------------------------------------------------------- 1 | compute($registry, $entity); 22 | } 23 | 24 | return $registry; 25 | } 26 | 27 | /** 28 | * 29 | * @throws RelationException 30 | */ 31 | protected function compute(Registry $registry, Entity $entity): void 32 | { 33 | foreach ($entity->getRelations() as $relation) { 34 | if (!$relation->getOptions()->has(self::STATIC_LINK)) { 35 | continue; 36 | } 37 | 38 | $target = $relation->getTarget(); 39 | if ($registry->hasEntity($target)) { 40 | // no need to resolve 41 | continue; 42 | } 43 | 44 | $relation->setTarget($this->resolve($registry, $target)); 45 | $relation->getOptions()->remove(self::STATIC_LINK); 46 | } 47 | } 48 | 49 | /** 50 | * 51 | * @return non-empty-string 52 | */ 53 | protected function resolve(Registry $registry, string $target): string 54 | { 55 | if (!interface_exists($target)) { 56 | throw new RelationException("Unable to resolve static link to non interface target `{$target}`"); 57 | } 58 | 59 | $found = null; 60 | foreach ($registry->getIterator() as $entity) { 61 | if ($entity->getClass() === null) { 62 | continue; 63 | } 64 | 65 | try { 66 | $candidate = new \ReflectionClass($entity->getClass()); 67 | } catch (\ReflectionException $e) { 68 | throw new RegistryException($e->getMessage(), $e->getCode(), $e); 69 | } 70 | 71 | if ($candidate->isSubclassOf($target) || $candidate->implementsInterface($target)) { 72 | if ($found !== null) { 73 | throw new RelationException("Ambiguous static link to `{$target}`"); 74 | } 75 | 76 | $found = $entity->getClass(); 77 | } 78 | } 79 | 80 | if ($found !== null) { 81 | return $found; 82 | } 83 | 84 | throw new RelationException("Unable to resolve static link to `{$target}`"); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Generator/SyncTables.php: -------------------------------------------------------------------------------- 1 | getRegistryDbList($registry) as $dbName) { 28 | $reflector = new Reflector(); 29 | 30 | foreach ($registry as $regEntity) { 31 | if ( 32 | !$registry->hasTable($regEntity) 33 | || $registry->getDatabase($regEntity) !== $dbName 34 | || $regEntity->getOptions()->has(self::READONLY_SCHEMA) 35 | ) { 36 | continue; 37 | } 38 | 39 | $reflector->addTable($registry->getTableSchema($regEntity)); 40 | } 41 | 42 | try { 43 | $reflector->run(); 44 | } catch (\Throwable $e) { 45 | throw new SyncException($e->getMessage(), (int) $e->getCode(), $e); 46 | } 47 | } 48 | 49 | return $registry; 50 | } 51 | 52 | private function getRegistryDbList(Registry $registry): array 53 | { 54 | $databases = []; 55 | foreach ($registry as $regEntity) { 56 | if (!$registry->hasTable($regEntity)) { 57 | continue; 58 | } 59 | $dbName = $registry->getDatabase($regEntity); 60 | if (in_array($dbName, $databases, true)) { 61 | continue; 62 | } 63 | 64 | $databases[] = $dbName; 65 | } 66 | 67 | return $databases; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Generator/ValidateEntities.php: -------------------------------------------------------------------------------- 1 | getIterator() as $entity) { 21 | if (count($entity->getFields()) === 0) { 22 | throw new EntityException( 23 | "Entity `{$entity->getRole()}` is empty", 24 | ); 25 | } 26 | } 27 | 28 | return $registry; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/GeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class Registry implements \IteratorAggregate 18 | { 19 | /** @var Entity[] */ 20 | private array $entities = []; 21 | 22 | private DatabaseProviderInterface $dbal; 23 | private \SplObjectStorage $tables; 24 | private \SplObjectStorage $children; 25 | private \SplObjectStorage $relations; 26 | private Defaults $defaults; 27 | 28 | public function __construct(DatabaseProviderInterface $dbal, ?Defaults $defaults = null) 29 | { 30 | $this->dbal = $dbal; 31 | $this->tables = new \SplObjectStorage(); 32 | $this->children = new \SplObjectStorage(); 33 | $this->relations = new \SplObjectStorage(); 34 | $this->defaults = $defaults ?? new Defaults(); 35 | } 36 | 37 | public function register(Entity $entity): self 38 | { 39 | foreach ($this->entities as $e) { 40 | if ($e->getRole() == $entity->getRole()) { 41 | throw new RegistryException("Duplicate entity `{$e->getRole()}`"); 42 | } 43 | } 44 | 45 | $this->entities[] = $entity; 46 | $this->tables[$entity] = null; 47 | $this->children[$entity] = []; 48 | $this->relations[$entity] = []; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * @param string $role Entity role of class. 55 | */ 56 | public function hasEntity(string $role): bool 57 | { 58 | foreach ($this->entities as $entity) { 59 | if ($entity->getRole() === $role || $entity->getClass() === $role) { 60 | return true; 61 | } 62 | } 63 | 64 | return false; 65 | } 66 | 67 | /** 68 | * Get entity by it's role. 69 | * 70 | * @param string $role Entity role or class name. 71 | * 72 | * @throws RegistryException 73 | */ 74 | public function getEntity(string $role): Entity 75 | { 76 | foreach ($this->entities as $entity) { 77 | if ($entity->getRole() == $role || $entity->getClass() === $role) { 78 | return $entity; 79 | } 80 | } 81 | 82 | throw new RegistryException("Undefined entity `{$role}`"); 83 | } 84 | 85 | public function getIterator(): \Traversable 86 | { 87 | return new \ArrayIterator($this->entities); 88 | } 89 | 90 | /** 91 | * Assign child entity to parent entity. 92 | * Be careful! This method merges the parent and child entity schemas. 93 | * If you don't need to merge schemas {@see Registry::registerChildWithoutMerge()}. 94 | * 95 | * @throws RegistryException 96 | */ 97 | public function registerChild(Entity $parent, Entity $child): void 98 | { 99 | $this->registerChildWithoutMerge($parent, $child); 100 | 101 | // merge parent and child schema 102 | $parent->merge($child); 103 | } 104 | 105 | public function registerChildWithoutMerge(Entity $parent, Entity $child): void 106 | { 107 | if (!$this->hasInstance($parent)) { 108 | throw new RegistryException("Undefined entity `{$parent->getRole()}`"); 109 | } 110 | 111 | $children = $this->children[$parent]; 112 | $children[] = $child; 113 | $this->children[$parent] = $children; 114 | } 115 | 116 | /** 117 | * Get all assigned children entities. 118 | * 119 | * @return Entity[] 120 | */ 121 | public function getChildren(Entity $entity): array 122 | { 123 | if (!$this->hasInstance($entity)) { 124 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 125 | } 126 | 127 | return $this->children[$entity]; 128 | } 129 | 130 | /** 131 | * Associate entity with table. 132 | * 133 | * @param non-empty-string $table 134 | * 135 | * @throws RegistryException 136 | * @throws DBALException 137 | */ 138 | public function linkTable(Entity $entity, ?string $database, string $table): self 139 | { 140 | if (!$this->hasInstance($entity)) { 141 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 142 | } 143 | 144 | $database = $this->dbal->database($database)->getName(); 145 | 146 | $schema = null; 147 | foreach ($this->tables as $other) { 148 | $association = $this->tables[$other]; 149 | 150 | if ($association === null) { 151 | continue; 152 | } 153 | 154 | // avoid schema duplication 155 | if ($association['database'] === $database && $association['table'] === $table) { 156 | $schema = $association['schema']; 157 | break; 158 | } 159 | } 160 | 161 | if ($schema === null) { 162 | $dbTable = $this->dbal->database($database)->table($table); 163 | if (!\method_exists($dbTable, 'getSchema')) { 164 | throw new RegistryException('Unable to retrieve table schema.'); 165 | } 166 | $schema = $dbTable->getSchema(); 167 | } 168 | 169 | $this->tables[$entity] = [ 170 | 'database' => $database, 171 | 'table' => $table, 172 | 'schema' => $schema, 173 | ]; 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * @throws RegistryException 180 | */ 181 | public function hasTable(Entity $entity): bool 182 | { 183 | if (!$this->hasInstance($entity)) { 184 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 185 | } 186 | 187 | return $this->tables[$entity] !== null; 188 | } 189 | 190 | /** 191 | * @throws RegistryException 192 | */ 193 | public function getDatabase(Entity $entity): string 194 | { 195 | if (!$this->hasTable($entity)) { 196 | throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); 197 | } 198 | 199 | return $this->tables[$entity]['database']; 200 | } 201 | 202 | /** 203 | * @return non-empty-string 204 | * @throws RegistryException 205 | * 206 | */ 207 | public function getTable(Entity $entity): string 208 | { 209 | if (!$this->hasTable($entity)) { 210 | throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); 211 | } 212 | 213 | return $this->tables[$entity]['table']; 214 | } 215 | 216 | /** 217 | * @throws RegistryException 218 | */ 219 | public function getTableSchema(Entity $entity): AbstractTable 220 | { 221 | if (!$this->hasTable($entity)) { 222 | throw new RegistryException("Entity `{$entity->getRole()}` has no assigned table"); 223 | } 224 | 225 | return $this->tables[$entity]['schema']; 226 | } 227 | 228 | /** 229 | * Create entity relation. 230 | * 231 | * @throws RegistryException 232 | * @throws RelationException 233 | */ 234 | public function registerRelation(Entity $entity, string $name, RelationInterface $relation): void 235 | { 236 | if (!$this->hasInstance($entity)) { 237 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 238 | } 239 | 240 | $relations = $this->relations[$entity]; 241 | $relations[$name] = $relation; 242 | $this->relations[$entity] = $relations; 243 | } 244 | 245 | /** 246 | * @throws RegistryException 247 | */ 248 | public function hasRelation(Entity $entity, string $name): bool 249 | { 250 | if (!$this->hasInstance($entity)) { 251 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 252 | } 253 | 254 | return isset($this->relations[$entity][$name]); 255 | } 256 | 257 | public function getRelation(Entity $entity, string $name): RelationInterface 258 | { 259 | if (!$this->hasRelation($entity, $name)) { 260 | throw new RegistryException("Undefined relation `{$entity->getRole()}`.`{$name}`"); 261 | } 262 | 263 | return $this->relations[$entity][$name]; 264 | } 265 | 266 | /** 267 | * Get all relations assigned with given entity. 268 | * 269 | * @return RelationInterface[] 270 | */ 271 | public function getRelations(Entity $entity): array 272 | { 273 | if (!$this->hasInstance($entity)) { 274 | throw new RegistryException("Undefined entity `{$entity->getRole()}`"); 275 | } 276 | 277 | return $this->relations[$entity]; 278 | } 279 | 280 | public function getDefaults(): Defaults 281 | { 282 | return $this->defaults; 283 | } 284 | 285 | protected function hasInstance(Entity $entity): bool 286 | { 287 | return array_search($entity, $this->entities, true) !== false; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Relation/BelongsTo.php: -------------------------------------------------------------------------------- 1 | true, 28 | 29 | // do not pre-load relation by default 30 | Relation::LOAD => Relation::LOAD_PROMISE, 31 | 32 | // nullable by default 33 | Relation::NULLABLE => false, 34 | 35 | // link to parent entity primary key by default 36 | Relation::INNER_KEY => '{relation}_{outerKey}', 37 | 38 | // default field name for inner key 39 | Relation::OUTER_KEY => '{target:primaryKey}', 40 | 41 | // rendering options 42 | RelationSchema::INDEX_CREATE => true, 43 | RelationSchema::FK_CREATE => true, 44 | RelationSchema::FK_ACTION => 'CASCADE', 45 | RelationSchema::FK_ON_DELETE => null, 46 | ]; 47 | 48 | public function compute(Registry $registry): void 49 | { 50 | parent::compute($registry); 51 | 52 | $source = $registry->getEntity($this->source); 53 | $target = $registry->getEntity($this->target); 54 | 55 | $this->normalizeContextFields($source, $target); 56 | 57 | // create target outer field 58 | $this->createRelatedFields( 59 | $target, 60 | Relation::OUTER_KEY, 61 | $source, 62 | Relation::INNER_KEY, 63 | ); 64 | } 65 | 66 | public function render(Registry $registry): void 67 | { 68 | $source = $registry->getEntity($this->source); 69 | $target = $registry->getEntity($this->target); 70 | 71 | $sourceTable = $registry->getTableSchema($source); 72 | 73 | $innerFields = $this->getFields($source, Relation::INNER_KEY); 74 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 75 | 76 | if ($this->options->get(self::INDEX_CREATE) && $innerFields->count() > 0) { 77 | $sourceTable->index($innerFields->getColumnNames()); 78 | } 79 | 80 | if ($this->options->get(self::FK_CREATE)) { 81 | $this->createForeignCompositeKey( 82 | $registry, 83 | $target, 84 | $source, 85 | $outerFields, 86 | $innerFields, 87 | $this->options->get(self::INDEX_CREATE), 88 | ); 89 | } 90 | } 91 | 92 | /** 93 | * 94 | * @return Entity[] 95 | */ 96 | public function inverseTargets(Registry $registry): array 97 | { 98 | return [ 99 | $registry->getEntity($this->target), 100 | ]; 101 | } 102 | 103 | /** 104 | * 105 | * @throws RelationException 106 | * 107 | */ 108 | public function inverseRelation(RelationInterface $relation, string $into, ?int $load = null): RelationInterface 109 | { 110 | if (!$relation instanceof HasOne && !$relation instanceof HasMany) { 111 | throw new RelationException('BelongsTo relation can only be inversed into HasOne or HasMany'); 112 | } 113 | 114 | return $relation->withContext( 115 | $into, 116 | $this->target, 117 | $this->source, 118 | $this->options->withOptions([ 119 | Relation::LOAD => $load, 120 | Relation::INNER_KEY => $this->options->get(Relation::OUTER_KEY), 121 | Relation::OUTER_KEY => $this->options->get(Relation::INNER_KEY), 122 | ]), 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Relation/Embedded.php: -------------------------------------------------------------------------------- 1 | Relation::LOAD_EAGER, 22 | self::EMBEDDED_PREFIX => '', 23 | ]; 24 | 25 | public function compute(Registry $registry): void 26 | { 27 | $source = $registry->getEntity($this->source); 28 | $target = $registry->getEntity($this->target); 29 | 30 | $targetRole = $target->getRole(); 31 | $sourceRole = $source->getRole(); 32 | \assert($targetRole !== null && $sourceRole !== null); 33 | 34 | // each embedded entity must isolated 35 | $target = clone $target; 36 | $target->setRole($sourceRole . ':' . $targetRole . ':' . $this->name); 37 | $targetRole = $target->getRole(); 38 | \assert($targetRole !== null); 39 | 40 | // embedded entity must point to the same table as parent entity 41 | $registry->register($target); 42 | $registry->linkTable($target, $registry->getDatabase($source), $registry->getTable($source)); 43 | 44 | // isolated 45 | $this->target = $targetRole; 46 | 47 | $prefix = $this->getOptions()->get(self::EMBEDDED_PREFIX); 48 | assert(\is_string($prefix)); 49 | foreach ($target->getFields() as $field) { 50 | /** @var non-empty-string $columnName */ 51 | $columnName = $prefix . $field->getColumn(); 52 | $field->setColumn($columnName); 53 | } 54 | 55 | foreach ($source->getFields() as $name => $field) { 56 | if ($field->isPrimary()) { 57 | // sync primary keys 58 | if ($target->getFields()->has($name)) { 59 | throw new EmbeddedPrimaryKeyException($target, $name); 60 | } 61 | $target->getFields()->set($name, $field); 62 | } 63 | } 64 | 65 | parent::compute($registry); 66 | } 67 | 68 | public function render(Registry $registry): void 69 | { 70 | // relation does not require any column rendering besides actual tables 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Relation/HasMany.php: -------------------------------------------------------------------------------- 1 | true, 28 | 29 | // do not pre-load relation by default 30 | Relation::LOAD => Relation::LOAD_PROMISE, 31 | 32 | // not nullable by default 33 | Relation::NULLABLE => false, 34 | 35 | // custom where condition 36 | Relation::WHERE => [], 37 | 38 | // custom orderBy rules 39 | Relation::ORDER_BY => [], 40 | 41 | // link to parent entity primary key by default 42 | Relation::INNER_KEY => '{source:primaryKey}', 43 | 44 | // default field name for inner key 45 | Relation::OUTER_KEY => '{source:role}_{innerKey}', 46 | 47 | // default collection. 48 | Relation::COLLECTION_TYPE => null, 49 | 50 | // rendering options 51 | RelationSchema::INDEX_CREATE => true, 52 | RelationSchema::FK_CREATE => true, 53 | RelationSchema::FK_ACTION => 'CASCADE', 54 | RelationSchema::FK_ON_DELETE => null, 55 | ]; 56 | 57 | public function compute(Registry $registry): void 58 | { 59 | parent::compute($registry); 60 | 61 | $source = $registry->getEntity($this->source); 62 | $target = $registry->getEntity($this->target); 63 | 64 | $this->normalizeContextFields($source, $target); 65 | 66 | // create target outer field 67 | $this->createRelatedFields( 68 | $source, 69 | Relation::INNER_KEY, 70 | $target, 71 | Relation::OUTER_KEY, 72 | ); 73 | } 74 | 75 | public function render(Registry $registry): void 76 | { 77 | $source = $registry->getEntity($this->source); 78 | $target = $registry->getEntity($this->target); 79 | 80 | $targetTable = $registry->getTableSchema($target); 81 | 82 | $innerFields = $this->getFields($source, Relation::INNER_KEY); 83 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 84 | 85 | if ($this->options->get(self::INDEX_CREATE) && $outerFields->count() > 0) { 86 | $targetTable->index($outerFields->getColumnNames()); 87 | } 88 | 89 | if ($this->options->get(self::FK_CREATE)) { 90 | $this->createForeignCompositeKey( 91 | $registry, 92 | $source, 93 | $target, 94 | $innerFields, 95 | $outerFields, 96 | $this->options->get(self::INDEX_CREATE), 97 | ); 98 | } 99 | } 100 | 101 | /** 102 | * 103 | * @return Entity[] 104 | */ 105 | public function inverseTargets(Registry $registry): array 106 | { 107 | return [ 108 | $registry->getEntity($this->target), 109 | ]; 110 | } 111 | 112 | /** 113 | * 114 | * @throws RelationException 115 | * 116 | */ 117 | public function inverseRelation(RelationInterface $relation, string $into, ?int $load = null): RelationInterface 118 | { 119 | if (!$relation instanceof BelongsTo && !$relation instanceof RefersTo) { 120 | throw new RelationException('HasMany relation can only be inversed into BelongsTo or RefersTo.'); 121 | } 122 | 123 | if (!empty($this->options->get(Relation::WHERE))) { 124 | throw new RelationException('Unable to inverse HasMany relation with where scope.'); 125 | } 126 | 127 | return $relation->withContext( 128 | $into, 129 | $this->target, 130 | $this->source, 131 | $this->options->withOptions([ 132 | Relation::LOAD => $load, 133 | Relation::INNER_KEY => $this->options->get(Relation::OUTER_KEY), 134 | Relation::OUTER_KEY => $this->options->get(Relation::INNER_KEY), 135 | ]), 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Relation/HasOne.php: -------------------------------------------------------------------------------- 1 | true, 28 | 29 | // do not pre-load relation by default 30 | Relation::LOAD => Relation::LOAD_PROMISE, 31 | 32 | // not nullable by default 33 | Relation::NULLABLE => false, 34 | 35 | // link to parent entity primary key by default 36 | Relation::INNER_KEY => '{source:primaryKey}', 37 | 38 | // default field name for inner key 39 | Relation::OUTER_KEY => '{source:role}_{innerKey}', 40 | 41 | // rendering options 42 | RelationSchema::INDEX_CREATE => true, 43 | RelationSchema::FK_CREATE => true, 44 | RelationSchema::FK_ACTION => 'CASCADE', 45 | RelationSchema::FK_ON_DELETE => null, 46 | ]; 47 | 48 | public function compute(Registry $registry): void 49 | { 50 | parent::compute($registry); 51 | 52 | $source = $registry->getEntity($this->source); 53 | $target = $registry->getEntity($this->target); 54 | 55 | $this->normalizeContextFields($source, $target); 56 | 57 | // create target outer field 58 | $this->createRelatedFields( 59 | $source, 60 | Relation::INNER_KEY, 61 | $target, 62 | Relation::OUTER_KEY, 63 | ); 64 | } 65 | 66 | public function render(Registry $registry): void 67 | { 68 | $source = $registry->getEntity($this->source); 69 | $target = $registry->getEntity($this->target); 70 | 71 | $targetTable = $registry->getTableSchema($target); 72 | 73 | $innerFields = $this->getFields($source, Relation::INNER_KEY); 74 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 75 | 76 | if ($this->options->get(self::INDEX_CREATE) && count($outerFields) > 0) { 77 | $targetTable->index($outerFields->getColumnNames()); 78 | } 79 | 80 | if ($this->options->get(self::FK_CREATE)) { 81 | $this->createForeignCompositeKey( 82 | $registry, 83 | $source, 84 | $target, 85 | $innerFields, 86 | $outerFields, 87 | $this->options->get(self::INDEX_CREATE), 88 | ); 89 | } 90 | } 91 | 92 | /** 93 | * 94 | * @return Entity[] 95 | */ 96 | public function inverseTargets(Registry $registry): array 97 | { 98 | return [ 99 | $registry->getEntity($this->target), 100 | ]; 101 | } 102 | 103 | /** 104 | * 105 | * @throws RelationException 106 | * 107 | */ 108 | public function inverseRelation(RelationInterface $relation, string $into, ?int $load = null): RelationInterface 109 | { 110 | if (!$relation instanceof BelongsTo && !$relation instanceof RefersTo) { 111 | throw new RelationException('HasOne relation can only be inversed into BelongsTo or RefersTo'); 112 | } 113 | 114 | return $relation->withContext( 115 | $into, 116 | $this->target, 117 | $this->source, 118 | $this->options->withOptions([ 119 | Relation::LOAD => $load, 120 | Relation::INNER_KEY => $this->options->get(Relation::OUTER_KEY), 121 | Relation::OUTER_KEY => $this->options->get(Relation::INNER_KEY), 122 | ]), 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Relation/ManyToMany.php: -------------------------------------------------------------------------------- 1 | true, 28 | 29 | // do not pre-load relation by default 30 | Relation::LOAD => Relation::LOAD_PROMISE, 31 | 32 | // nullable by default 33 | Relation::NULLABLE => false, 34 | 35 | // custom where condition 36 | Relation::WHERE => [], 37 | 38 | // custom orderBy rules 39 | Relation::ORDER_BY => [], 40 | 41 | // inner key of parent record will be used to fill "THROUGH_INNER_KEY" in pivot table 42 | Relation::INNER_KEY => '{source:primaryKey}', 43 | 44 | // we are going to use primary key of outer table to fill "THROUGH_OUTER_KEY" in pivot table 45 | // this is technically "inner" key of outer record, we will name it "outer key" for simplicity 46 | Relation::OUTER_KEY => '{target:primaryKey}', 47 | 48 | // through entity role name 49 | Relation::THROUGH_ENTITY => null, 50 | 51 | // name field where parent record inner key will be stored in pivot table, role + innerKey 52 | // by default 53 | Relation::THROUGH_INNER_KEY => '{source:role}_{innerKey}', 54 | 55 | // name field where inner key of outer record (outer key) will be stored in pivot table, 56 | // role + outerKey by default 57 | Relation::THROUGH_OUTER_KEY => '{target:role}_{outerKey}', 58 | 59 | // custom pivot where 60 | Relation::THROUGH_WHERE => [], 61 | 62 | // default collection. 63 | Relation::COLLECTION_TYPE => null, 64 | 65 | // rendering options 66 | RelationSchema::INDEX_CREATE => true, 67 | RelationSchema::FK_CREATE => true, 68 | RelationSchema::FK_ACTION => 'CASCADE', 69 | RelationSchema::FK_ON_DELETE => null, 70 | ]; 71 | 72 | /** 73 | * @psalm-suppress PossiblyNullArgument 74 | */ 75 | public function compute(Registry $registry): void 76 | { 77 | parent::compute($registry); 78 | 79 | $source = $registry->getEntity($this->source); 80 | $target = $registry->getEntity($this->target); 81 | $throughEntity = $this->options->get(Relation::THROUGH_ENTITY); 82 | 83 | if ($throughEntity === null) { 84 | throw new RelationException(sprintf( 85 | 'Relation ManyToMany must have the throughEntity declaration (%s => ? => %s).', 86 | $source->getRole(), 87 | $target->getRole(), 88 | )); 89 | } 90 | 91 | $through = $registry->getEntity($throughEntity); 92 | 93 | if ($registry->getDatabase($source) !== $registry->getDatabase($target)) { 94 | throw new RelationException(sprintf( 95 | 'Relation ManyToMany can only link entities from same database (%s, %s).', 96 | $source->getRole(), 97 | $target->getRole(), 98 | )); 99 | } 100 | 101 | if ($registry->getDatabase($source) !== $registry->getDatabase($through)) { 102 | throw new RelationException(sprintf( 103 | 'Relation ManyToMany can only link entities from same database (%s, %s)', 104 | $source->getRole(), 105 | $through->getRole(), 106 | )); 107 | } 108 | 109 | $this->normalizeContextFields($source, $target); 110 | $this->normalizeContextFields($source, $through, ['innerKey', 'outerKey', 'throughInnerKey', 'throughOuterKey']); 111 | 112 | $this->createRelatedFields( 113 | $source, 114 | Relation::INNER_KEY, 115 | $through, 116 | Relation::THROUGH_INNER_KEY, 117 | ); 118 | 119 | $this->createRelatedFields( 120 | $target, 121 | Relation::OUTER_KEY, 122 | $through, 123 | Relation::THROUGH_OUTER_KEY, 124 | ); 125 | } 126 | 127 | public function render(Registry $registry): void 128 | { 129 | $source = $registry->getEntity($this->source); 130 | $target = $registry->getEntity($this->target); 131 | 132 | $through = $registry->getEntity($this->options->get(Relation::THROUGH_ENTITY)); 133 | 134 | $sourceFields = $this->getFields($source, Relation::INNER_KEY); 135 | $targetFields = $this->getFields($target, Relation::OUTER_KEY); 136 | 137 | $throughSourceFields = $this->getFields($through, Relation::THROUGH_INNER_KEY); 138 | $throughTargetFields = $this->getFields($through, Relation::THROUGH_OUTER_KEY); 139 | 140 | $table = $registry->getTableSchema($through); 141 | 142 | if ($this->options->get(self::INDEX_CREATE)) { 143 | $index = array_merge($throughSourceFields->getColumnNames(), $throughTargetFields->getColumnNames()); 144 | if (count($index) > 0 && !$this->hasIndex($table, $index)) { 145 | $table->index($index)->unique(!$this->hasIndex($table, $index, strictOrder: false, unique: true)); 146 | } 147 | } 148 | 149 | if ($this->options->get(self::FK_CREATE)) { 150 | $this->createForeignCompositeKey( 151 | $registry, 152 | $source, 153 | $through, 154 | $sourceFields, 155 | $throughSourceFields, 156 | $this->options->get(self::INDEX_CREATE), 157 | ); 158 | $this->createForeignCompositeKey( 159 | $registry, 160 | $target, 161 | $through, 162 | $targetFields, 163 | $throughTargetFields, 164 | $this->options->get(self::INDEX_CREATE), 165 | ); 166 | } 167 | } 168 | 169 | /** 170 | * 171 | * @return Entity[] 172 | */ 173 | public function inverseTargets(Registry $registry): array 174 | { 175 | return [ 176 | $registry->getEntity($this->target), 177 | ]; 178 | } 179 | 180 | /** 181 | * 182 | * @throws RelationException 183 | * 184 | */ 185 | public function inverseRelation(RelationInterface $relation, string $into, ?int $load = null): RelationInterface 186 | { 187 | if (!$relation instanceof self) { 188 | throw new RelationException('ManyToMany relation can only be inversed into ManyToMany'); 189 | } 190 | 191 | if (!empty($this->options->get(Relation::THROUGH_WHERE)) || !empty($this->options->get(Relation::WHERE))) { 192 | throw new RelationException('Unable to inverse ManyToMany relation with where scope.'); 193 | } 194 | 195 | return $relation->withContext( 196 | $into, 197 | $this->target, 198 | $this->source, 199 | $this->options->withOptions([ 200 | Relation::LOAD => $load, 201 | Relation::INNER_KEY => $this->options->get(Relation::OUTER_KEY), 202 | Relation::OUTER_KEY => $this->options->get(Relation::INNER_KEY), 203 | Relation::THROUGH_INNER_KEY => $this->options->get(Relation::THROUGH_OUTER_KEY), 204 | Relation::THROUGH_OUTER_KEY => $this->options->get(Relation::THROUGH_INNER_KEY), 205 | ]), 206 | ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Relation/Morphed/BelongsToMorphed.php: -------------------------------------------------------------------------------- 1 | true, 29 | 30 | // do not pre-load relation by default 31 | Relation::LOAD => Relation::LOAD_PROMISE, 32 | 33 | // nullable by default 34 | Relation::NULLABLE => true, 35 | 36 | // default field name for inner key 37 | Relation::OUTER_KEY => '{target:primaryKey}', 38 | 39 | // link to parent entity primary key by default 40 | Relation::INNER_KEY => '{relation}_{outerKey}', 41 | 42 | // link to parent entity primary key by default 43 | Relation::MORPH_KEY => '{relation}_role', 44 | 45 | // rendering options 46 | RelationSchema::INDEX_CREATE => true, 47 | RelationSchema::MORPH_KEY_LENGTH => 32, 48 | ]; 49 | 50 | public function compute(Registry $registry): void 51 | { 52 | // compute local key 53 | $this->options = $this->options->withContext([ 54 | 'source:primaryKey' => $this->getPrimaryColumns($registry->getEntity($this->source)), 55 | ]); 56 | 57 | $source = $registry->getEntity($this->source); 58 | 59 | [$outerKeys, $outerFields] = $this->findOuterKey($registry, $this->target); 60 | 61 | // register primary key reference 62 | $this->options = $this->options->withContext([ 63 | 'target:primaryKey' => $outerKeys, 64 | ]); 65 | 66 | $outerKeys = array_combine($outerKeys, (array) $this->options->get(Relation::INNER_KEY)); 67 | 68 | // create target outer field 69 | foreach ($outerKeys as $key => $morphKey) { 70 | $outerField = $outerFields->get($key); 71 | 72 | $this->ensureField( 73 | $source, 74 | $morphKey, 75 | $outerField, 76 | $this->options->get(Relation::NULLABLE), 77 | ); 78 | } 79 | 80 | foreach ((array) $this->options->get(Relation::MORPH_KEY) as $key) { 81 | $this->ensureMorphField( 82 | $source, 83 | $key, 84 | $this->options->get(RelationSchema::MORPH_KEY_LENGTH), 85 | $this->options->get(Relation::NULLABLE), 86 | ); 87 | } 88 | } 89 | 90 | public function render(Registry $registry): void 91 | { 92 | $source = $registry->getEntity($this->source); 93 | $innerFields = $this->getFields($source, Relation::INNER_KEY); 94 | $morphFields = $this->getFields($source, Relation::MORPH_KEY); 95 | 96 | $this->mergeIndex($registry, $source, $innerFields, $morphFields); 97 | } 98 | 99 | /** 100 | * 101 | * @return Entity[] 102 | */ 103 | public function inverseTargets(Registry $registry): array 104 | { 105 | return iterator_to_array($this->findTargets($registry, $this->target)); 106 | } 107 | 108 | /** 109 | * 110 | * @throws RelationException 111 | * 112 | */ 113 | public function inverseRelation(RelationInterface $relation, string $into, ?int $load = null): RelationInterface 114 | { 115 | if (!$relation instanceof MorphedHasOne && !$relation instanceof MorphedHasMany) { 116 | throw new RelationException( 117 | 'BelongsToMorphed relation can only be inversed into MorphedHasOne or MorphedHasMany', 118 | ); 119 | } 120 | 121 | return $relation->withContext( 122 | $into, 123 | $this->target, 124 | $this->source, 125 | $this->options->withOptions([ 126 | Relation::LOAD => $load, 127 | Relation::INNER_KEY => $this->options->get(Relation::OUTER_KEY), 128 | Relation::OUTER_KEY => $this->options->get(Relation::INNER_KEY), 129 | ]), 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Relation/Morphed/MorphedHasMany.php: -------------------------------------------------------------------------------- 1 | true, 25 | 26 | // do not pre-load relation by default 27 | Relation::LOAD => Relation::LOAD_PROMISE, 28 | 29 | // nullable by default 30 | Relation::NULLABLE => true, 31 | 32 | // default field name for inner key 33 | Relation::OUTER_KEY => '{relation}_{source:primaryKey}', 34 | 35 | // link to parent entity primary key by default 36 | Relation::INNER_KEY => '{source:primaryKey}', 37 | 38 | // link to parent entity primary key by default 39 | Relation::MORPH_KEY => '{relation}_role', 40 | 41 | // default collection. 42 | Relation::COLLECTION_TYPE => null, 43 | 44 | // rendering options 45 | RelationSchema::INDEX_CREATE => true, 46 | RelationSchema::MORPH_KEY_LENGTH => 32, 47 | ]; 48 | 49 | public function compute(Registry $registry): void 50 | { 51 | parent::compute($registry); 52 | 53 | $source = $registry->getEntity($this->source); 54 | $target = $registry->getEntity($this->target); 55 | 56 | // create target outer field 57 | $this->createRelatedFields( 58 | $source, 59 | Relation::INNER_KEY, 60 | $target, 61 | Relation::OUTER_KEY, 62 | ); 63 | 64 | // create target outer field 65 | $this->ensureMorphField( 66 | $target, 67 | $this->options->get(Relation::MORPH_KEY), 68 | $this->options->get(RelationSchema::MORPH_KEY_LENGTH), 69 | $this->options->get(Relation::NULLABLE), 70 | ); 71 | } 72 | 73 | public function render(Registry $registry): void 74 | { 75 | $target = $registry->getEntity($this->target); 76 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 77 | $morphFields = $this->getFields($target, Relation::MORPH_KEY); 78 | 79 | $this->mergeIndex($registry, $target, $outerFields, $morphFields); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Relation/Morphed/MorphedHasOne.php: -------------------------------------------------------------------------------- 1 | true, 25 | 26 | // do not pre-load relation by default 27 | Relation::LOAD => Relation::LOAD_PROMISE, 28 | 29 | // nullable by default 30 | Relation::NULLABLE => false, 31 | 32 | // default field name for inner key 33 | Relation::OUTER_KEY => '{relation}_{source:primaryKey}', 34 | 35 | // link to parent entity primary key by default 36 | Relation::INNER_KEY => '{source:primaryKey}', 37 | 38 | // link to parent entity primary key by default 39 | Relation::MORPH_KEY => '{relation}_role', 40 | 41 | // rendering options 42 | RelationSchema::INDEX_CREATE => true, 43 | RelationSchema::MORPH_KEY_LENGTH => 32, 44 | ]; 45 | 46 | public function compute(Registry $registry): void 47 | { 48 | parent::compute($registry); 49 | 50 | $source = $registry->getEntity($this->source); 51 | $target = $registry->getEntity($this->target); 52 | 53 | // create target outer field 54 | $this->createRelatedFields( 55 | $source, 56 | Relation::INNER_KEY, 57 | $target, 58 | Relation::OUTER_KEY, 59 | ); 60 | 61 | // create target outer field 62 | $this->ensureMorphField( 63 | $target, 64 | $this->options->get(Relation::MORPH_KEY), 65 | $this->options->get(RelationSchema::MORPH_KEY_LENGTH), 66 | $this->options->get(Relation::NULLABLE), 67 | ); 68 | } 69 | 70 | public function render(Registry $registry): void 71 | { 72 | $target = $registry->getEntity($this->target); 73 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 74 | $morphFields = $this->getFields($target, Relation::MORPH_KEY); 75 | 76 | $this->mergeIndex($registry, $target, $outerFields, $morphFields); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Relation/OptionSchema.php: -------------------------------------------------------------------------------- 1 | options; 26 | } 27 | 28 | /** 29 | * Create new option set with user provided options. 30 | */ 31 | public function withOptions(iterable $options): self 32 | { 33 | $r = clone $this; 34 | 35 | foreach ($options as $name => $value) { 36 | if (!array_key_exists($name, $r->aliases) && !array_key_exists($name, $r->template)) { 37 | throw new OptionException("Undefined relation option `{$name}`"); 38 | } 39 | 40 | $r->options[$name] = $value; 41 | } 42 | 43 | return $r; 44 | } 45 | 46 | /** 47 | * Create new option set with option rendering template. Template expect to allocate 48 | * relation options only in a integer constants. 49 | */ 50 | public function withTemplate(array $template): self 51 | { 52 | $r = clone $this; 53 | $r->template = $template; 54 | 55 | return $r; 56 | } 57 | 58 | /** 59 | * Create new option set with relation context values (i.e. relation name, target name and etc). 60 | */ 61 | public function withContext(array $context): self 62 | { 63 | $r = clone $this; 64 | $r->context += $context; 65 | 66 | return $r; 67 | } 68 | 69 | /** 70 | * Check if option has been defined. 71 | */ 72 | public function has(int $option): bool 73 | { 74 | return array_key_exists($option, $this->template); 75 | } 76 | 77 | /** 78 | * Get calculated option value. 79 | */ 80 | public function get(int $option): mixed 81 | { 82 | if (!$this->has($option)) { 83 | throw new OptionException("Undefined relation option `{$option}`"); 84 | } 85 | 86 | if (array_key_exists($option, $this->options)) { 87 | return $this->options[$option]; 88 | } 89 | 90 | // user defined value 91 | foreach ($this->aliases as $alias => $targetOption) { 92 | if ($targetOption === $option && isset($this->options[$alias])) { 93 | return $this->options[$alias]; 94 | } 95 | } 96 | 97 | // non template value 98 | $value = $this->template[$option]; 99 | if (!is_string($value)) { 100 | return $value; 101 | } 102 | 103 | $value = $this->calculate($option, $value); 104 | 105 | if (strpos($value, '|') !== false) { 106 | return array_filter(explode('|', $value)); 107 | } 108 | 109 | return $value; 110 | } 111 | 112 | public function __debugInfo(): array 113 | { 114 | $result = []; 115 | 116 | foreach ($this->template as $option => $value) { 117 | $value = $this->get($option); 118 | 119 | $alias = array_search($option, $this->aliases, true); 120 | $result[$alias] = $value; 121 | } 122 | 123 | return $result; 124 | } 125 | 126 | /** 127 | * Calculate option value using templating. 128 | */ 129 | private function calculate(int $option, string $value): string 130 | { 131 | foreach ($this->context as $name => $ctxValue) { 132 | $ctxValue = is_array($ctxValue) ? implode('|', $ctxValue) . '|' : $ctxValue; 133 | $value = $this->injectValue($name, $ctxValue, $value); 134 | } 135 | 136 | foreach ($this->aliases as $name => $targetOption) { 137 | if ($option !== $targetOption) { 138 | $value = $this->injectOption($name, $targetOption, $value); 139 | } 140 | } 141 | 142 | return $value; 143 | } 144 | 145 | private function injectOption(string $name, int $option, string $target): string 146 | { 147 | if (!str_contains($target, "{{$name}}")) { 148 | return $target; 149 | } 150 | 151 | $name = "{{$name}}"; 152 | $replace = $this->get($option); 153 | if (is_array($replace)) { 154 | return implode('|', array_map(static function (string $replace) use ($name, $target) { 155 | return str_replace($name, $replace, $target); 156 | }, $replace)); 157 | } 158 | 159 | return str_replace($name, $replace, $target); 160 | } 161 | 162 | private function injectValue(string $name, string $value, string $target): string 163 | { 164 | return str_replace("{{$name}}", $value, $target); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Relation/RefersTo.php: -------------------------------------------------------------------------------- 1 | true, 27 | 28 | // do not pre-load relation by default 29 | Relation::LOAD => Relation::LOAD_PROMISE, 30 | 31 | // nullable by default 32 | Relation::NULLABLE => true, 33 | 34 | // link to parent entity primary key by default 35 | Relation::INNER_KEY => '{relation}_{outerKey}', 36 | 37 | // default field name for inner key 38 | Relation::OUTER_KEY => '{target:primaryKey}', 39 | 40 | // rendering options 41 | RelationSchema::INDEX_CREATE => true, 42 | RelationSchema::FK_CREATE => true, 43 | RelationSchema::FK_ACTION => 'SET NULL', 44 | RelationSchema::FK_ON_DELETE => null, 45 | ]; 46 | 47 | public function compute(Registry $registry): void 48 | { 49 | parent::compute($registry); 50 | 51 | $source = $registry->getEntity($this->source); 52 | $target = $registry->getEntity($this->target); 53 | 54 | $this->normalizeContextFields($source, $target); 55 | 56 | // create target outer field 57 | $this->createRelatedFields( 58 | $target, 59 | Relation::OUTER_KEY, 60 | $source, 61 | Relation::INNER_KEY, 62 | ); 63 | } 64 | 65 | public function render(Registry $registry): void 66 | { 67 | $source = $registry->getEntity($this->source); 68 | $target = $registry->getEntity($this->target); 69 | 70 | $innerFields = $this->getFields($source, Relation::INNER_KEY); 71 | $outerFields = $this->getFields($target, Relation::OUTER_KEY); 72 | 73 | $table = $registry->getTableSchema($source); 74 | 75 | if ($this->options->get(self::INDEX_CREATE) && $innerFields->count() > 0) { 76 | $table->index($innerFields->getColumnNames()); 77 | } 78 | 79 | if ($this->options->get(self::FK_CREATE)) { 80 | $this->createForeignCompositeKey( 81 | $registry, 82 | $target, 83 | $source, 84 | $outerFields, 85 | $innerFields, 86 | $this->options->get(self::INDEX_CREATE), 87 | ); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Relation/RelationSchema.php: -------------------------------------------------------------------------------- 1 | source = $role; 68 | return $relation; 69 | } 70 | 71 | public function modifySchema(array &$schema): void 72 | { 73 | $schema[SchemaInterface::RELATIONS][$this->name] = $this->packSchema(); 74 | } 75 | 76 | /** 77 | * @param non-empty-string $source 78 | * @param non-empty-string $target 79 | */ 80 | public function withContext(string $name, string $source, string $target, OptionSchema $options): RelationInterface 81 | { 82 | $relation = clone $this; 83 | $relation->source = $source; 84 | $relation->target = $target; 85 | $relation->name = $name; 86 | 87 | $relation->options = $options->withTemplate(static::RELATION_SCHEMA)->withContext([ 88 | 'relation' => $name, 89 | 'source:role' => $source, 90 | 'target:role' => $target, 91 | ]); 92 | 93 | return $relation; 94 | } 95 | 96 | public function compute(Registry $registry): void 97 | { 98 | $this->options = $this->options->withContext([ 99 | 'source:primaryKey' => $this->getPrimaryColumns($registry->getEntity($this->source)), 100 | ]); 101 | 102 | if ($registry->hasEntity($this->target)) { 103 | $this->options = $this->options->withContext([ 104 | 'target:primaryKey' => $this->getPrimaryColumns($registry->getEntity($this->target)), 105 | ]); 106 | } 107 | } 108 | 109 | protected function getLoadMethod(): ?int 110 | { 111 | if (!$this->options->has(Relation::LOAD)) { 112 | return null; 113 | } 114 | 115 | switch ($this->options->get(Relation::LOAD)) { 116 | case 'eager': 117 | case Relation::LOAD_EAGER: 118 | return Relation::LOAD_EAGER; 119 | default: 120 | return Relation::LOAD_PROMISE; 121 | } 122 | } 123 | 124 | protected function getOptions(): OptionSchema 125 | { 126 | return $this->options; 127 | } 128 | 129 | /** 130 | * @throws RegistryException 131 | */ 132 | protected function getPrimaryColumns(Entity $entity): array 133 | { 134 | $columns = $entity->getPrimaryFields()->getNames(); 135 | 136 | if ($columns === []) { 137 | throw new RegistryException("Entity `{$entity->getRole()}` must have defined primary key"); 138 | } 139 | 140 | return $columns; 141 | } 142 | 143 | /** 144 | * @param array $columns 145 | * @param bool $strictOrder True means that fields order in the {@see $columns} argument is matter 146 | * @param bool $withSorting True means that fields will be compared taking into account the column values sorting 147 | * @param bool|null $unique Unique index or not. Null means both 148 | */ 149 | protected function hasIndex( 150 | AbstractTable $table, 151 | array $columns, 152 | bool $strictOrder = true, 153 | bool $withSorting = true, 154 | ?bool $unique = null, 155 | ): bool { 156 | if ($strictOrder && $withSorting && $unique === null) { 157 | return $table->hasIndex($columns); 158 | } 159 | $indexes = $table->getIndexes(); 160 | 161 | foreach ($indexes as $index) { 162 | if ($unique !== null && $index->isUnique() !== $unique) { 163 | continue; 164 | } 165 | $tableColumns = $withSorting ? $index->getColumnsWithSort() : $index->getColumns(); 166 | 167 | if (count($columns) !== count($tableColumns)) { 168 | continue; 169 | } 170 | 171 | if ($strictOrder ? $columns === $tableColumns : array_diff($columns, $tableColumns) === []) { 172 | return true; 173 | } 174 | } 175 | return false; 176 | } 177 | 178 | private function packSchema(): array 179 | { 180 | $schema = []; 181 | 182 | foreach (static::RELATION_SCHEMA as $option => $template) { 183 | if (in_array($option, static::EXCLUDE, true)) { 184 | continue; 185 | } 186 | 187 | $schema[$option] = $this->options->get($option); 188 | } 189 | 190 | // load option is not required in schema 191 | unset($schema[Relation::LOAD]); 192 | 193 | return [ 194 | Relation::TYPE => static::RELATION_TYPE, 195 | Relation::TARGET => $this->target, 196 | Relation::LOAD => $this->getLoadMethod(), 197 | Relation::SCHEMA => $schema, 198 | ]; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Relation/Traits/FieldTrait.php: -------------------------------------------------------------------------------- 1 | getFields()->get($this->getOptions()->get($field)); 23 | } catch (FieldException $e) { 24 | throw new RelationException( 25 | sprintf( 26 | 'Field `%s`.`%s` does not exists, referenced by `%s`', 27 | $entity->getRole() ?? 'unknown', 28 | $this->getOptions()->get($field), 29 | $this->source, 30 | ), 31 | $e->getCode(), 32 | $e, 33 | ); 34 | } 35 | } 36 | 37 | protected function getFields(Entity $entity, int $option): FieldMap 38 | { 39 | $fields = new FieldMap(); 40 | $keys = (array) $this->getOptions()->get($option); 41 | 42 | foreach ($keys as $key) { 43 | try { 44 | $fields->set($key, $entity->getFields()->get($key)); 45 | } catch (FieldException $e) { 46 | throw new RelationException( 47 | sprintf( 48 | 'Field `%s`.`%s` does not exists, referenced by `%s`.', 49 | $entity->getRole() ?? 'unknown', 50 | $key, 51 | $this->source, 52 | ), 53 | $e->getCode(), 54 | $e, 55 | ); 56 | } 57 | } 58 | 59 | return $fields; 60 | } 61 | 62 | protected function createRelatedFields( 63 | Entity $source, 64 | int $sourceKey, 65 | Entity $target, 66 | int $targetKey, 67 | ): void { 68 | $sourceFields = $this->getFields($source, $sourceKey); 69 | $targetColumns = (array) $this->options->get($targetKey); 70 | 71 | $sourceFieldNames = $sourceFields->getNames(); 72 | 73 | if (count($targetColumns) !== count($sourceFieldNames)) { 74 | throw new RegistryException( 75 | sprintf( 76 | 'Inconsistent amount of related fields. ' 77 | . 'Source entity: `%s`; keys: `%s`. Target entity: `%s`; keys: `%s`.', 78 | $source->getRole() ?? 'unknown', 79 | implode('`, `', $this->getFields($source, $sourceKey)->getColumnNames()), 80 | $target->getRole() ?? 'unknown', 81 | implode('`, `', $targetColumns), 82 | ), 83 | ); 84 | } 85 | 86 | $fields = array_combine($targetColumns, $sourceFieldNames); 87 | 88 | foreach ($fields as $targetColumn => $sourceFieldName) { 89 | $sourceField = $sourceFields->get($sourceFieldName); 90 | $this->ensureField( 91 | $target, 92 | $targetColumn, 93 | $sourceField, 94 | $this->options->get(Relation::NULLABLE), 95 | ); 96 | } 97 | } 98 | 99 | /** 100 | * This method tries to replace column names with property names in relations 101 | */ 102 | protected function normalizeContextFields( 103 | Entity $source, 104 | Entity $target, 105 | array $keys = ['innerKey', 'outerKey'], 106 | ): void { 107 | foreach ($keys as $key) { 108 | $options = $this->options->getOptions(); 109 | 110 | if (!isset($options[$key])) { 111 | continue; 112 | } 113 | 114 | $columns = (array) $options[$key]; 115 | 116 | foreach ($columns as $i => $column) { 117 | $entity = $key === 'innerKey' ? $source : $target; 118 | 119 | if ($entity->getFields()->hasColumn($column)) { 120 | $columns[$i] = $entity->getFields()->getKeyByColumnName($column); 121 | } 122 | } 123 | 124 | $this->options = $this->options->withOptions([ 125 | $key => $columns, 126 | ]); 127 | } 128 | } 129 | 130 | /** 131 | * @param non-empty-string $fieldName 132 | */ 133 | protected function ensureField(Entity $target, string $fieldName, Field $outerField, bool $nullable = false): void 134 | { 135 | // ensure that field will be indexed in memory for fast references 136 | $outerField->setReferenced(true); 137 | 138 | if ($target->getFields()->has($fieldName)) { 139 | // field already exists and defined by the user 140 | return; 141 | } 142 | 143 | $field = new Field(); 144 | $field->setEntityClass($target->getClass()); 145 | $field->setColumn($fieldName); 146 | $field->setTypecast($outerField->getTypecast()); 147 | // Copy attributes from outer to target 148 | foreach ($outerField->getAttributes() as $k => $v) { 149 | $field->getAttributes()->set($k, $v); 150 | } 151 | 152 | switch ($outerField->getType()) { 153 | case 'primary': 154 | $field->setType('int'); 155 | break; 156 | case 'bigPrimary': 157 | $field->setType('bigint'); 158 | break; 159 | case 'smallPrimary': 160 | $field->setType('smallint'); 161 | break; 162 | default: 163 | $field->setType($outerField->getType()); 164 | } 165 | 166 | if ($nullable) { 167 | $field->getOptions()->set(Column::OPT_NULLABLE, true); 168 | } 169 | 170 | $target->getFields()->set($fieldName, $field); 171 | } 172 | 173 | abstract protected function getOptions(): OptionSchema; 174 | } 175 | -------------------------------------------------------------------------------- /src/Relation/Traits/ForeignKeyTrait.php: -------------------------------------------------------------------------------- 1 | getDatabase($source) !== $registry->getDatabase($target)) { 28 | return; 29 | } 30 | 31 | $outerFields = (new FieldMap())->set($outerField->getColumn(), $outerField); 32 | $innerFields = (new FieldMap())->set($innerField->getColumn(), $innerField); 33 | 34 | $this->createForeignCompositeKey($registry, $source, $target, $outerFields, $innerFields, $indexCreate); 35 | } 36 | 37 | /** 38 | * Create foreign key between two entities with composite fields. Only when both entities are located 39 | * in a same database. 40 | */ 41 | final protected function createForeignCompositeKey( 42 | Registry $registry, 43 | Entity $source, 44 | Entity $target, 45 | FieldMap $innerFields, 46 | FieldMap $outerFields, 47 | bool $indexCreate = true, 48 | ): void { 49 | if ($registry->getDatabase($source) !== $registry->getDatabase($target)) { 50 | return; 51 | } 52 | 53 | $fkAction = $this->getOptions()->get(RelationSchema::FK_ACTION); 54 | $registry->getTableSchema($target) 55 | ->foreignKey($outerFields->getColumnNames(), $indexCreate) 56 | ->references($registry->getTable($source), $innerFields->getColumnNames()) 57 | ->onUpdate($fkAction) 58 | ->onDelete($this->getOptions()->get(RelationSchema::FK_ON_DELETE) ?? $fkAction); 59 | } 60 | 61 | abstract protected function getOptions(): OptionSchema; 62 | } 63 | -------------------------------------------------------------------------------- /src/Relation/Traits/MorphTrait.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | protected function findTargets(Registry $registry, string $interface): \Generator 27 | { 28 | foreach ($registry as $entity) { 29 | $class = $entity->getClass(); 30 | if ($class === null || !in_array($interface, class_implements($class))) { 31 | continue; 32 | } 33 | 34 | yield $entity; 35 | } 36 | } 37 | 38 | /** 39 | * @param non-empty-string $interface 40 | * 41 | * @return array Tuple [name, Field] 42 | * @throws RelationException 43 | * 44 | */ 45 | protected function findOuterKey(Registry $registry, string $interface): array 46 | { 47 | $keys = null; 48 | $fields = null; 49 | $prevEntity = null; 50 | 51 | foreach ($this->findTargets($registry, $interface) as $entity) { 52 | $primaryFields = $entity->getPrimaryFields(); 53 | $primaryKeys = $this->getPrimaryColumns($entity); 54 | 55 | if ($keys === null) { 56 | $keys = $primaryKeys; 57 | $fields = $primaryFields; 58 | $prevEntity = $entity; 59 | } elseif ($keys !== $primaryKeys && $prevEntity !== null) { 60 | throw new RelationException(sprintf( 61 | 'Inconsistent primary key reference (%s). PKs: (%s). Required PKs [%s]: (%s).', 62 | $entity->getRole() ?? 'unknown', 63 | implode(',', $primaryKeys), 64 | $prevEntity->getRole() ?? 'unknown', 65 | implode(',', $keys), 66 | )); 67 | } 68 | } 69 | 70 | if ($fields === null) { 71 | throw new RelationException('Unable to find morphed parent.'); 72 | } 73 | 74 | return [$keys, $fields]; 75 | } 76 | 77 | /** 78 | * @param non-empty-string $column 79 | */ 80 | protected function ensureMorphField(Entity $target, string $column, int $length, bool $nullable = false): void 81 | { 82 | if ($target->getFields()->has($column)) { 83 | // field already exists and defined by the user 84 | return; 85 | } 86 | 87 | $field = new Field(); 88 | $field->setEntityClass($target->getClass()); 89 | $field->setColumn($column); 90 | $field->setType(sprintf('string(%s)', $length)); 91 | 92 | if ($nullable) { 93 | $field->getOptions()->set(Column::OPT_NULLABLE, true); 94 | } 95 | 96 | $target->getFields()->set($column, $field); 97 | } 98 | 99 | protected function mergeIndex(Registry $registry, Entity $source, FieldMap ...$mergeMaps): void 100 | { 101 | $table = $registry->getTableSchema($source); 102 | 103 | if ($this->options->get(self::INDEX_CREATE)) { 104 | /** @psalm-suppress NamedArgumentNotAllowed */ 105 | $index = array_merge(...array_map( 106 | static function (FieldMap $map): array { 107 | return $map->getColumnNames(); 108 | }, 109 | $mergeMaps, 110 | )); 111 | 112 | if (count($index) > 0) { 113 | $table->index($index); 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/RelationInterface.php: -------------------------------------------------------------------------------- 1 | [a-z]+)(?: *\((?P[^\)]+)\))?/i'; 29 | 30 | /** @var Field */ 31 | private $field; 32 | 33 | /** @var string */ 34 | private $type; 35 | 36 | /** @var array */ 37 | private $typeOptions = []; 38 | 39 | /** 40 | * Parse field definition into table definition. 41 | * 42 | * @throws ColumnException 43 | * 44 | */ 45 | public static function parse(Field $field): self 46 | { 47 | $column = new self(); 48 | $column->field = $field; 49 | 50 | if (!preg_match(self::DEFINITION, $field->getType(), $type)) { 51 | throw new ColumnException("Invalid column type declaration in `{$field->getType()}`"); 52 | } 53 | 54 | $column->type = $type['type']; 55 | if (!empty($type['options'])) { 56 | $column->typeOptions = array_map('trim', explode(',', $type['options'] ?? '')); 57 | } 58 | 59 | return $column; 60 | } 61 | 62 | public function getName(): string 63 | { 64 | return $this->field->getColumn(); 65 | } 66 | 67 | /** 68 | * Get column type. 69 | * 70 | */ 71 | public function getType(): string 72 | { 73 | return $this->type; 74 | } 75 | 76 | public function isPrimary(): bool 77 | { 78 | return $this->field->isPrimary() || in_array($this->type, ['primary', 'bigPrimary']); 79 | } 80 | 81 | public function isNullable(): bool 82 | { 83 | if ($this->hasDefault() && $this->getDefault() === null) { 84 | return true; 85 | } 86 | 87 | return $this->hasOption(self::OPT_NULLABLE) && !$this->isPrimary(); 88 | } 89 | 90 | public function hasDefault(): bool 91 | { 92 | if ($this->isPrimary()) { 93 | return false; 94 | } 95 | 96 | return $this->hasOption(self::OPT_DEFAULT); 97 | } 98 | 99 | /** 100 | * @return mixed 101 | * @throws ColumnException 102 | * 103 | */ 104 | public function getDefault() 105 | { 106 | if (!$this->hasDefault()) { 107 | throw new ColumnException("No default value on `{$this->field->getColumn()}`"); 108 | } 109 | 110 | return $this->field->getOptions()->get(self::OPT_DEFAULT); 111 | } 112 | 113 | /** 114 | * Render column definition. 115 | * 116 | * @throws ColumnException 117 | */ 118 | public function render(AbstractColumn $column): void 119 | { 120 | $column->nullable($this->isNullable()); 121 | 122 | try { 123 | // bypassing call to AbstractColumn->type method (or specialized column method) 124 | if (\method_exists($column, $this->type) && $this->typeOptions !== []) { 125 | call_user_func_array([$column, $this->type], $this->typeOptions); 126 | } else { 127 | call_user_func_array([$column, 'type'], \array_merge([$this->type], $this->typeOptions)); 128 | } 129 | } catch (\Throwable $e) { 130 | throw new ColumnException( 131 | "Invalid column type definition in '{$column->getTable()}'.'{$column->getName()}'", 132 | (int) $e->getCode(), 133 | $e, 134 | ); 135 | } 136 | 137 | if ($this->isNullable()) { 138 | $column->defaultValue(null); 139 | } 140 | 141 | if ($this->hasDefault() && $this->getDefault() !== null) { 142 | $column->defaultValue($this->getDefault()); 143 | return; 144 | } 145 | 146 | if ($this->hasOption(self::OPT_CAST_DEFAULT)) { 147 | // cast default value 148 | $column->defaultValue($this->castDefault($column)); 149 | } 150 | 151 | $column->setAttributes(\iterator_to_array($this->field->getAttributes())); 152 | } 153 | 154 | /** 155 | * 156 | * @return bool|float|int|string 157 | */ 158 | private function castDefault(AbstractColumn $column) 159 | { 160 | if (in_array($column->getAbstractType(), ['timestamp', 'datetime', 'time', 'date'])) { 161 | return 0; 162 | } 163 | 164 | if ($column->getAbstractType() === 'enum') { 165 | // we can use first enum value as default 166 | return $column->getEnumValues()[0]; 167 | } 168 | 169 | switch ($column->getType()) { 170 | case AbstractColumn::INT: 171 | return 0; 172 | case AbstractColumn::FLOAT: 173 | return 0.0; 174 | case AbstractColumn::BOOL: 175 | return false; 176 | } 177 | 178 | return ''; 179 | } 180 | 181 | private function hasOption(string $option): bool 182 | { 183 | return $this->field->getOptions()->has($option); 184 | } 185 | } 186 | --------------------------------------------------------------------------------