diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef20b82..b105fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: matrix: include: # ORM 4 requires PHP >= 7.2 + # Entity fields are not casted to integers in PHP 7.4 and 8.0 - { php: '7.4', orm: '4' } - { php: '8.0', orm: '4' } - { php: '8.1', orm: '4' } diff --git a/src/ORM/AutoHydratorRecursive.php b/src/ORM/AutoHydratorRecursive.php index ffecaf4..0b379b3 100644 --- a/src/ORM/AutoHydratorRecursive.php +++ b/src/ORM/AutoHydratorRecursive.php @@ -4,376 +4,265 @@ namespace Bancer\NativeQueryMapper\ORM; -use Cake\Datasource\EntityInterface; use Cake\ORM\Table; -use Cake\ORM\Entity; -use Cake\ORM\TableRegistry; -use Cake\ORM\Association\HasMany; -use Cake\ORM\Association\HasOne; -use Cake\ORM\Association\BelongsTo; -use Cake\ORM\Association\BelongsToMany; +use Cake\Datasource\EntityInterface; class AutoHydratorRecursive { + protected Table $rootTable; + /** - * A list of uknown aliases. - * * @var string[] */ - private array $unknownAliases = []; + protected array $associationTypes = [ + 'hasOne', + 'belongsTo', + 'hasMany', + 'belongsToMany', + ]; - protected Table $rootTable; + /** + * @var string[] + */ + protected array $aliases; /** @var array SQL alias => fields */ protected array $aliasMap = []; - /** @var array SQL alias => Table instance */ - protected array $tableByAlias = []; - /** * Precomputed mapping strategy. * - * @var array + * @var mixed[] */ protected array $mappingStrategy = []; /** - * @param Table $rootTable - * @param mixed[] $rows + * [ + * '{alias}' => [ + * '{hash}' => {index}, + * ], + * ] + * + * @var int[][] + */ + protected array $entitiesMap = []; + + /** + * @var \Cake\Datasource\EntityInterface[] + */ + protected array $entities = []; + + /** + * @param \Cake\ORM\Table $rootTable + * @param mixed[] $rows The result set of `$this->stmt->fetchAll(\PDO::FETCH_ASSOC);`. */ public function __construct(Table $rootTable, array $rows) { $this->rootTable = $rootTable; - $first = $rows[0] ?? []; - if (!is_array($first)) { + $firstRow = $rows[0] ?? []; + if (!is_array($firstRow)) { throw new \InvalidArgumentException('First element of the result set is not an array'); } - $keys = array_keys($first); - $this->aliasMap = $this->buildAliasMapFromRowKeys($keys); - $allAliases = array_keys($this->aliasMap); - $this->unknownAliases = array_combine($allAliases, $allAliases); - $this->buildMappingStrategy(); + $keys = array_keys($firstRow); + $this->aliasMap = $this->buildAliasMap($keys); + $this->aliases = array_keys($this->aliasMap); + $strategy = new MappingStrategy($rootTable, $this->aliases); + $this->mappingStrategy = $strategy->build()->toArray(); } /** - * @param (int|string)[] $keys + * @param (string|null)[] $keys * @return string[][] */ - protected function buildAliasMapFromRowKeys(array $keys): array + protected function buildAliasMap(array $keys): array { $map = []; - foreach ($keys as $k) { - if (!is_string($k)) { - throw new UnknownAliasException('SQL alias is not a string'); + foreach ($keys as $key) { + if (!is_string($key) || !str_contains($key, '__')) { + throw new UnknownAliasException("Alias '$key' is invalid"); } - if (!str_contains($k, '__')) { - continue; + [$alias, $field] = explode('__', $key, 2); + if (mb_strlen($alias) <= 0 || mb_strlen($field) <= 0) { + throw new UnknownAliasException("Alias '$key' is invalid"); } - [$alias, $field] = explode('__', $k, 2); $map[$alias][] = $field; } return $map; } /** - * Precompute mapping strategy and resolve table aliases. + * @param mixed[][] $rows + * @return \Cake\Datasource\EntityInterface[] */ - protected function buildMappingStrategy(): void + public function hydrateMany(array $rows): array { - $rootAlias = $this->rootTable->getAlias(); - $this->tableByAlias[$rootAlias] = $this->rootTable; - $this->mappingStrategy = []; - foreach ($this->aliasMap as $alias => $_fields) { - if ($alias === $rootAlias) { - continue; - } - $table = $this->resolveTableByAliasRecursive($alias); - if ($table === null) { - throw new UnknownAliasException( - "SQL alias '$alias' does not match any reachable Table from '$rootAlias'." - ); - } - $this->tableByAlias[$alias] = $table; - } - $allAliases = array_keys($this->aliasMap); - $aliasesToMap = array_combine($allAliases, $allAliases); - foreach ($this->tableByAlias as $alias => $table) { - if (isset($aliasesToMap[$alias])) { - $this->mappingStrategy[$alias] = []; - foreach ($table->associations() as $assoc) { - $type = null; - if ($assoc instanceof HasOne) { - $type = 'hasOne'; - } elseif ($assoc instanceof BelongsTo) { - $type = 'belongsTo'; - } elseif ($assoc instanceof BelongsToMany) { - $type = 'belongsToMany'; - } elseif ($assoc instanceof HasMany) { - $type = 'hasMany'; - } - if ($type === null) { - continue; - } - $childAlias = $assoc->getTarget()->getAlias(); - if (!isset($aliasesToMap[$childAlias])) { - continue; - } - $entry = []; - if ($assoc instanceof BelongsToMany) { - $through = $assoc->getThrough(); - if ($through === null) { - $through = $assoc->junction(); - } - if (is_object($through)) { - $through = $through->getAlias(); - } - $entry['through'] = $through; - if (isset($aliasesToMap[$through])) { - unset($aliasesToMap[$through]); - } - } - $entry['property'] = $assoc->getProperty(); - $this->mappingStrategy[$alias][$type][$childAlias] = $entry; - unset($aliasesToMap[$childAlias]); - } - } + $parsed = $this->parse($rows); + foreach ($parsed as $row) { + $this->map($this->mappingStrategy, $row); } - } - - protected function resolveTableByAlias(string $alias): ?Table - { - return $this->tableByAlias[$alias] ?? null; + return $this->entities; } /** - * Resolves a table instance based on a given SQL alias. - * - * This method performs a breadth-first search (BFS) starting from the root table - * to find a table that matches the provided alias. It traverses all associations - * (HasOne, HasMany, BelongsTo, BelongsToMany) recursively and also considers - * junction tables for BelongsToMany associations. * - * For BelongsToMany associations, the junction table is only enqueued if it exists - * and is not already visited. If the alias matches a junction table, the method - * retrieves it from the TableLocator. - * - * @param string $alias The SQL alias to resolve. - * @return \Cake\ORM\Table|null The Table instance corresponding to the alias, or null if not found. + * @param mixed[] $mappingStrategy + * @param mixed[][] $row + * @param \Cake\Datasource\EntityInterface $parent + * @param string $parentAssociation */ - protected function resolveTableByAliasRecursive(string $alias): ?Table - { - $visited = []; - $queue = [$this->rootTable]; - while ($queue && !empty($this->unknownAliases)) { - /** @var Table $table */ - $table = array_shift($queue); - $visited[$table->getAlias()] = true; - foreach ($table->associations() as $assoc) { - $target = $assoc->getTarget(); - $ta = $target->getAlias(); - if (isset($this->unknownAliases[$ta])) { - unset($this->unknownAliases[$ta]); - if ($ta === $alias) { - return $target; + protected function map( + array $mappingStrategy, + array $row, + ?EntityInterface $parent = null, + ?string $parentAssociation = null + ): void { + /** @var array{ + * className?: class-string<\Cake\Datasource\EntityInterface>, + * propertyName?: string, + * hasOne?: array, + * belongsTo?: array, + * hasMany?: array, + * belongsToMany?: array + * } $node */ + foreach ($mappingStrategy as $alias => $node) { + if (!isset($node['className'])) { + throw new \RuntimeException("Unknown entity class name for alias $alias"); + } + $className = $node['className']; + if ($parent === null) { + // root entity + $hash = $this->computeFieldsHash($row[$alias]); + if (!isset($this->entitiesMap[$alias][$hash])) { + // create new entity + $entity = $this->constructEntity($className, $row[$alias]); + if ($entity === null) { + throw new \RuntimeException('Failed to construct root entity'); } + $this->entities[] = $entity; + $this->entitiesMap[$alias][$hash] = array_key_last($this->entities); + } else { + // edit already mapped entity + $entityIndex = $this->entitiesMap[$alias][$hash]; + $entity = $this->entities[$entityIndex]; + } + } else { + // child entity + if (!isset($node['propertyName'])) { + throw new \RuntimeException("Unknown property name for alias $alias"); } - if (!isset($visited[$ta])) { - $queue[] = $target; + if (in_array($parentAssociation, ['hasOne', 'belongsTo'])) { + if (!$parent->has($node['propertyName'])) { + // create new entity + //TODO: do not create entity if all fields are null + $entity = $this->constructEntity($className, $row[$alias]); + $parent->set($node['propertyName'], $entity); + $parent->clean(); + } else { + // edit already mapped entity + $entity = $parent->get($node['propertyName']); + } } - if ($assoc instanceof BelongsToMany) { - $through = $assoc->getThrough(); - if ($through !== null) { - if (is_object($through)) { - $through = $through->getAlias(); + if (in_array($parentAssociation, ['hasMany', 'belongsToMany'])) { + $siblings = $parent->get($node['propertyName']); + if (!is_array($siblings)) { + $siblings = []; + } + $parentHash = spl_object_hash($parent); + $hash = $this->computeFieldsHash($row[$alias], $parentHash); + if (!isset($this->entitiesMap[$alias][$hash])) { + // create new entity + //TODO: do not create entity if all fields are null + $entity = $this->constructEntity($className, $row[$alias]); + if ($entity !== null) { + $siblings[] = $entity; + $this->entitiesMap[$alias][$hash] = array_key_last($siblings); } - if (isset($this->unknownAliases[$through])) { - unset($this->unknownAliases[$through]); - if ($through === $alias) { - return TableRegistry::getTableLocator()->get($through); - } + } else { + // edit already mapped entity + $entityIndex = $this->entitiesMap[$alias][$hash]; + $entity = $siblings[$entityIndex]; + } + $parent->set($node['propertyName'], $siblings); + $parent->clean(); + } + } + if ($this->hasAssociations($node)) { + foreach ($this->associationTypes as $associationType) { + if (isset($node[$associationType])) { + if (!is_array($node[$associationType])) { + $message = "Association '$associationType' is not an array in mapping strategy"; + throw new \RuntimeException($message); } - if (!isset($visited[$through])) { - $queue[] = TableRegistry::getTableLocator()->get($through); + if (!isset($entity) || !($entity instanceof EntityInterface)) { + throw new \RuntimeException('Parent entity must be an instance of EntityInterface'); } + $this->map($node[$associationType], $row, $entity, $associationType); } } } } - return null; } /** - * @param mixed[][] $rows - * @return \Cake\Datasource\EntityInterface[] + * @param class-string<\Cake\Datasource\EntityInterface> $className + * @param mixed[] $fields + * @return \Cake\Datasource\EntityInterface|null */ - public function hydrateMany(array $rows): array + protected function constructEntity(string $className, array $fields): ?EntityInterface { - $results = []; - $rootAlias = $this->rootTable->getAlias(); - foreach ($rows as $row) { - $tree = $this->buildEntityRecursive($this->rootTable, $row); - $root = $tree[$rootAlias]; - $key = $this->entityKey($root, $this->rootTable); - if (!isset($results[$key])) { - $results[$key] = $root; - } else { - $this->mergeEntityCollections($results[$key], $root); + $isEmpty = true; + foreach ($fields as $value) { + if ($value !== null) { + $isEmpty = false; + continue; } } - return array_values($results); + if ($isEmpty) { + return null; + } + $options = [ + 'markClean' => true, + 'markNew' => false, + ]; + return new $className($fields, $options); } /** - * @param Table $table - * @param mixed[] $row - * @param mixed[] $visited - * @return \Cake\Datasource\EntityInterface[] + * @param mixed[] $fields + * @param string|null $parentEntityHash + * @return string */ - protected function buildEntityRecursive( - Table $table, - array $row, - array &$visited = [] - ): array { - $alias = $table->getAlias(); - // prevent infinite recursion - if (isset($visited[$alias])) { - return []; - } - $visited[$alias] = true; - $out = []; - if (!isset($this->aliasMap[$alias])) { - unset($visited[$alias]); - return []; - } - $data = []; - foreach ($this->aliasMap[$alias] as $field) { - $data[$field] = $row["{$alias}__{$field}"] ?? null; - } - $entity = $table->newEntity( - $data, - [ - 'associated' => [], - 'markNew' => false, - 'accessibleFields' => ['*' => true], - ] - ); - $out[$alias] = $entity; - foreach ($this->mappingStrategy[$alias] ?? [] as $type => $children) { - if (is_array($children)) { - foreach ($children as $childAlias => $assocData) { - if (!isset($this->aliasMap[$childAlias])) { - continue; - } - $childTable = $this->tableByAlias[$childAlias]; - $tree = $this->buildEntityRecursive($childTable, $row, $visited); - if (!$tree) { - continue; - } - $childEntity = $tree[$childAlias]; - if ($type === 'belongsToMany') { - $throughAlias = null; - if (is_array($assocData) && isset($assocData['through'])) { - $throughAlias = $assocData['through']; - } - if (is_string($throughAlias) && isset($this->aliasMap[$throughAlias])) { - $throughTable = $this->tableByAlias[$throughAlias]; - $jTree = $this->buildEntityRecursive($throughTable, $row, $visited); - if ($jTree) { - $childEntity->set('_joinData', [$jTree[$throughAlias]]); - $out += $jTree; - } - } - } - $prop = null; - if (is_array($assocData) && isset($assocData['property'])) { - $prop = $assocData['property']; - } - if ($type === 'hasMany' || $type === 'belongsToMany') { - if (!is_string($prop)) { - $prop = $childAlias; - } - $list = $entity->get($prop); - if (!is_array($list)) { - $list = []; - } - $list[] = $childEntity; - $entity->set($prop, $list); - } else { - if (is_string($prop)) { - $entity->set($prop, $childEntity); - } - } - $out += $tree; - } - } - } - unset($visited[$alias]); - return $out; + protected function computeFieldsHash(array $fields, ?string $parentEntityHash = null): string + { + $serialized = serialize($fields); + return md5($serialized . $parentEntityHash); } - protected function mergeEntityCollections(EntityInterface $into, EntityInterface $from): void + /** + * @param mixed[] $node + * @return bool + */ + protected function hasAssociations(array $node): bool { - foreach ($from->toArray() as $prop => $val) { - if (!is_array($val)) { - continue; - } - $existing = $into->get($prop); - if (!is_array($existing)) { - $existing = []; - } - $merged = array_merge($existing, $val); - $unique = []; - $seen = []; - foreach ($merged as $child) { - if (!$child instanceof Entity) { - $unique[] = $child; - continue; - } - $alias = (string)$child->getSource(); - $table = $this->resolveTableByAlias($alias); - if (!$table) { - $oid = spl_object_id($child); - if (!isset($seen[$oid])) { - $seen[$oid] = true; - $unique[] = $child; - } - continue; - } - $pk = $table->getPrimaryKey(); - if (is_array($pk)) { - $complexPrimaryKey = function ($p) use ($child) { - /** @var string|null $primaryKeyValue */ - $primaryKeyValue = $child->get($p); - return (string)$primaryKeyValue; - }; - $value = implode('|', array_map($complexPrimaryKey, $pk)); - } else { - /** @var string|null $primaryKeyValue */ - $primaryKeyValue = $child->get($pk); - $value = (string)$primaryKeyValue; - } - if (!isset($seen[$value])) { - $seen[$value] = true; - $unique[] = $child; - } - } - $into->set($prop, $unique); - } + $keys = array_keys($node); + return array_intersect($this->associationTypes, $keys) !== []; } - protected function entityKey(EntityInterface $e, Table $table): string + /** + * @param mixed[][] $rows + * @return mixed[][][] + */ + protected function parse(array $rows): array { - $pk = $table->getPrimaryKey(); - if (is_array($pk)) { - $complexPrimaryKey = function ($p) use ($e) { - /** @var string|null $primaryKeyValue */ - $primaryKeyValue = $e->get($p); - return (string)($primaryKeyValue ?? ''); - }; - return implode('|', array_map($complexPrimaryKey, $pk)); + $results = []; + foreach ($rows as $row) { + $models = []; + foreach ($row as $columnName => $columnValue) { + [$alias, $field] = explode('__', $columnName, 2); + $models[$alias][$field] = $columnValue; + } + $results[] = $models; } - /** @var string|null $primaryKeyValue */ - $primaryKeyValue = $e->get($pk); - return (string)($primaryKeyValue ?? spl_object_id($e)); + return $results; } } diff --git a/src/ORM/MappingStrategy.php b/src/ORM/MappingStrategy.php new file mode 100644 index 0000000..e00482c --- /dev/null +++ b/src/ORM/MappingStrategy.php @@ -0,0 +1,224 @@ +rootTable = $rootTable; + if ($aliases === []) { + throw new UnknownAliasException('Every column of the query must use aliases'); + } + $this->aliasList = $aliases; + $this->unknownAliases = array_combine($aliases, $aliases); + $rootAlias = $rootTable->getAlias(); + if (!isset($this->unknownAliases[$rootAlias])) { + throw new UnknownAliasException("The query must use root table alias '$rootAlias'"); + } + unset($this->unknownAliases[$rootAlias]); + } + + public function build(): self + { + $rootAlias = $this->rootTable->getAlias(); + // --- Process root table non-recursively --- + $firstLevelChildren = $this->scanRootLevel($this->rootTable); + $this->mappings[$rootAlias] = $firstLevelChildren; + // --- Recursively process all remaining unknown aliases --- + foreach ($firstLevelChildren as $assocType => $children) { + if (is_array($children)) { + foreach ($children as $childAlias => $childValue) { + $childMappings = $this->scanTableRecursive($childAlias); + $mappings = Hash::merge($this->mappings[$rootAlias][$assocType][$childAlias], $childMappings); + $this->mappings[$rootAlias][$assocType][$childAlias] = $mappings; + } + } + } + if ($this->unknownAliases !== []) { + throw new UnknownAliasException('Failed to map some aliases: ' . $this->unknownAliasesToString()); + } + return $this; + } + + /** + * Process a table associations one level only (non-recursively). + * + * @param \Cake\ORM\Table $table Query's root table. + * @return mixed[] + */ + private function scanRootLevel(Table $table): array + { + $unknownAliasesCount = count($this->unknownAliases); + /** @var mixed[] $result */ + $result = [ + 'className' => $table->getEntityClass(), + ]; + /** @var \Cake\ORM\Association $assoc */ + foreach ($table->associations() as $assoc) { + $type = $this->assocType($assoc); + if ($type === null) { + continue; + } + $target = $assoc->getTarget(); + $alias = $target->getAlias(); + if (!isset($this->unknownAliases[$alias])) { + continue; + } + unset($this->unknownAliases[$alias]); + $firstLevelAssoc = [ + 'className' => $target->getEntityClass(), + 'propertyName' => $assoc->getProperty(), + ]; + if ($assoc instanceof BelongsToMany) { + $through = $assoc->getThrough() ?? $assoc->junction(); + if (is_object($through)) { + $through = $through->getAlias(); + } + if (isset($this->unknownAliases[$through])) { + $firstLevelAssoc['hasOne'][$through] = [ + 'className' => $assoc->junction()->getEntityClass(), + 'propertyName' => Inflector::underscore(Inflector::singularize($through)), + ]; + unset($this->unknownAliases[$through]); + } + } + $result[$type][$alias] = $firstLevelAssoc; + } + if ($unknownAliasesCount > 0 && $unknownAliasesCount === count($this->unknownAliases)) { + throw new UnknownAliasException( + 'None of the root table associations match any remaining aliases: ' . + $this->unknownAliasesToString() + ); + } + return $result; + } + + /** + * Recursively process associations starting from non-root tables. + * + * @param string $alias Model alias. + * @return mixed[] + */ + private function scanTableRecursive(string $alias): array + { + if (!in_array($alias, $this->aliasList)) { + return []; + } + $table = $this->fetchTable($alias); + /** @var mixed[] $result */ + $result = [ + 'className' => $table->getEntityClass(), + ]; + foreach ($table->associations() as $assoc) { + $type = $this->assocType($assoc); + if (!$type) { + continue; + } + $target = $assoc->getTarget(); + $childAlias = $target->getAlias(); + if (!isset($this->unknownAliases[$childAlias])) { + continue; + } + unset($this->unknownAliases[$childAlias]); + if ($assoc instanceof BelongsToMany) { + $through = $assoc->getThrough() ?? $assoc->junction(); + if (is_object($through)) { + $through = $through->getAlias(); + } + $result[$type][$childAlias]['hasOne'][$through] = [ + 'className' => $assoc->junction()->getEntityClass(), + 'propertyName' => Inflector::underscore(Inflector::singularize($through)), + ]; + if (isset($this->unknownAliases[$through])) { + unset($this->unknownAliases[$through]); + } + } else { + $childChildren = $this->scanTableRecursive($childAlias); + $childChildren['propertyName'] = $assoc->getProperty(); + $result[$type][$childAlias] = $childChildren; + } + } + return $result; + } + + /** + * Returns the name of association type. + * + * @param \Cake\ORM\Association $assoc Model association. + * @return string|null + */ + private function assocType(Association $assoc): ?string + { + $map = [ + HasOne::class => 'hasOne', + BelongsTo::class => 'belongsTo', + BelongsToMany::class => 'belongsToMany', + HasMany::class => 'hasMany', + ]; + foreach ($map as $class => $type) { + if ($assoc instanceof $class) { + return $type; + } + } + return null; + } + + /** + * Returns a tree-like array describing Alias-Entity mappings. + * + * @return mixed[] + */ + public function toArray(): array + { + return $this->mappings; + } + + private function unknownAliasesToString(): string + { + return implode(', ', array_keys($this->unknownAliases)); + } +} diff --git a/tests/TestApp/Model/Entity/Country.php b/tests/TestApp/Model/Entity/Country.php new file mode 100644 index 0000000..e2db66c --- /dev/null +++ b/tests/TestApp/Model/Entity/Country.php @@ -0,0 +1,11 @@ +hasMany('Users', ['className' => UsersTable::class]); + } +} diff --git a/tests/TestApp/Model/Table/UsersTable.php b/tests/TestApp/Model/Table/UsersTable.php index dc8b43d..f3c41b6 100644 --- a/tests/TestApp/Model/Table/UsersTable.php +++ b/tests/TestApp/Model/Table/UsersTable.php @@ -19,6 +19,7 @@ class UsersTable extends Table public function initialize(array $config): void { parent::initialize($config); + $this->belongsTo('Countries', ['className' => CountriesTable::class]); $this->hasOne('Profiles', ['className' => ProfilesTable::class]); } } diff --git a/tests/TestCase/ORM/MappingStrategyTest.php b/tests/TestCase/ORM/MappingStrategyTest.php new file mode 100644 index 0000000..65e5ea5 --- /dev/null +++ b/tests/TestCase/ORM/MappingStrategyTest.php @@ -0,0 +1,133 @@ +fetchTable(ArticlesTable::class); + $aliases = [ + 'Articles', + 'ArticlesTags', + 'Comments', + 'Countries', + 'Profiles', + 'Tags', + 'Users', + ]; + $strategy = new MappingStrategy($ArticlesTable, $aliases); + $actual = $strategy->build()->toArray(); + $expected = [ + 'Articles' => [ + 'className' => Article::class, + 'belongsTo' => [ + 'Users' => [ + 'className' => User::class, + 'propertyName' => 'user', + 'belongsTo' => [ + 'Countries' => [ + 'className' => Country::class, + 'propertyName' => 'country', + ], + ], + 'hasOne' => [ + 'Profiles' => [ + 'className' => Profile::class, + 'propertyName' => 'profile', + ], + ], + ], + ], + 'belongsToMany' => [ + 'Tags' => [ + 'className' => Tag::class, + 'propertyName' => 'tags', + 'hasOne' => [ + 'ArticlesTags' => [ + 'className' => Entity::class, + 'propertyName' => 'articles_tag', + ], + ], + ], + ], + 'hasMany' => [ + 'Comments' => [ + 'className' => Comment::class, + 'propertyName' => 'comments', + ], + ], + ], + ]; + $this->assertSame($expected, $actual); + } + + public function testBelongsToMany(): void + { + $articles = $this->fetchTable(ArticlesTable::class); + $aliases = [ + 'Articles', + 'Tags', + ]; + $strategy = new MappingStrategy($articles, $aliases); + $actual = $strategy->build()->toArray(); + $expected = [ + 'Articles' => [ + 'className' => Article::class, + 'belongsToMany' => [ + 'Tags' => [ + 'className' => Tag::class, + 'propertyName' => 'tags', + ], + ], + ], + ]; + $this->assertSame($expected, $actual); + } + + public function testBelongsToManyFetchJoinTable(): void + { + $articles = $this->fetchTable(ArticlesTable::class); + $aliases = [ + 'Articles', + 'Tags', + 'ArticlesTags', + ]; + $strategy = new MappingStrategy($articles, $aliases); + $actual = $strategy->build()->toArray(); + $expected = [ + 'Articles' => [ + 'className' => Article::class, + 'belongsToMany' => [ + 'Tags' => [ + 'className' => Tag::class, + 'propertyName' => 'tags', + 'hasOne' => [ + 'ArticlesTags' => [ + 'className' => Entity::class, + 'propertyName' => 'articles_tag', + ], + ], + ], + ], + ], + ]; + $this->assertSame($expected, $actual); + } +} diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index 467a33a..cd1069d 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -20,10 +20,26 @@ class NativeQueryMapperTest extends TestCase { use LocatorAwareTrait; + /** + * @param \Cake\Datasource\EntityInterface[] $expected + * @param \Cake\Datasource\EntityInterface[] $actual + */ + private function assertEqualsEntities(array $expected, array $actual): void + { + $expectedCount = count($expected); + $actualCount = count($actual); + static::assertSame($expectedCount, $actualCount); + for ($i = 0; $i < $actualCount; $i++) { + $message = "Entities at index $i are not equal"; + //static::assertEquals($expected[$i], $actual[$i], $message); + static::assertEquals($expected[$i]->toArray(), $actual[$i]->toArray(), $message); + } + } + public function testInvalidAlias(): void { $this->expectException(UnknownAliasException::class); - $this->expectExceptionMessage("SQL alias 'a' does not match any reachable Table from 'Articles'"); + $this->expectExceptionMessage("The query must use root table alias 'Articles'"); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); $stmt = $ArticlesTable->prepareSQL(" @@ -69,6 +85,11 @@ public function testSimplestSelect(): void 'title' => 'Article 1', ]; static::assertSame($expected, $actual[0]->toArray()); + $cakeEntities = $ArticlesTable->find() + ->select(['id', 'title']) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); } public function testSelectHasMany(): void @@ -110,6 +131,16 @@ public function testSelectHasMany(): void ], ]; static::assertSame($expected, $actual[0]->toArray()); + $cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.article_id', 'Comments.content'], + ], + ]) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); } public function testSelectBelongsTo(): void @@ -140,7 +171,17 @@ public function testSelectBelongsTo(): void 'title' => 'Article 1', ], ]; + $cakeEntities = $CommentsTable->find() + ->select(['Comments.id', 'Comments.article_id', 'Comments.content']) + ->contain([ + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title'], + ], + ]) + ->toArray(); static::assertSame($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); } public function testHasOne(): void @@ -172,9 +213,20 @@ public function testHasOne(): void ], ]; static::assertSame($expected, $actual[0]->toArray()); + $cakeEntities = $UsersTable->find() + ->select(['Users.id', 'Users.username']) + ->contain([ + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.user_id', 'Profiles.bio'], + ], + ]) + ->toArray(); + static::assertSame($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); } - public function testBelongsToMany(): void + public function testBelongsToManySimple(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); @@ -212,6 +264,17 @@ public function testBelongsToMany(): void ], ]; static::assertSame($expected, $actual[0]->toArray()); + /*$cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + ], + ]) + ->toArray(); + static::assertSame($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ } public function testBelongsToManyFetchJoinTable(): void @@ -247,27 +310,37 @@ public function testBelongsToManyFetchJoinTable(): void [ 'id' => 1, 'name' => 'Tech', - '_joinData' => [ - [ - 'id' => 1, - 'article_id' => 1, - 'tag_id' => 1, - ], + 'articles_tag' => [ + 'id' => 1, + 'article_id' => 1, + 'tag_id' => 1, ], ], [ 'id' => 2, 'name' => 'Food', - '_joinData' => [ - [ - 'id' => 2, - 'article_id' => 1, - 'tag_id' => 2, - ], + 'articles_tag' => [ + 'id' => 2, + 'article_id' => 1, + 'tag_id' => 2, ], ], ], ]; static::assertSame($expected, $actual[0]->toArray()); + /*$cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + 'ArticlesTags' => [ + 'fields' => ['ArticlesTags.id', 'ArticlesTags.article_id', 'ArticlesTags.tag_id'], + ], + ], + ]) + ->toArray(); + static::assertSame($cakeEntities[0]->toArray(), $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ } }