diff --git a/src/Styles/Plural.php b/src/Styles/Plural.php index fda8a4d..61a8923 100644 --- a/src/Styles/Plural.php +++ b/src/Styles/Plural.php @@ -9,7 +9,6 @@ use function implode; use function preg_match; use function preg_replace; -use function ucfirst; /** * Default plural table style familiar from frameworks such as Rails, Kohana, @@ -20,32 +19,20 @@ * id id id id * name author_id name post_id * title category_id + * + * Scope/method names stay PHP-conventional singular camelCase (`post`, + * `postCategory`); only the *table* name pluralizes. That makes `realName` the + * single point of difference from {@see Standard} — class resolution, foreign + * keys, and junction names are all singular/snake and inherited unchanged + * (`composed` is itself expressed through `realName`). */ final class Plural extends Standard { - public function remoteIdentifier(string $name): string - { - return $this->pluralToSingular($name) . '_id'; - } - - public function styledName(string $name): string - { - $pieces = array_map($this->pluralToSingular(...), explode('_', $name)); - - return ucfirst($this->separatorToCamelCase(implode('_', $pieces), '_')); - } - - public function composed(string $left, string $right): string + public function realName(string $name): string { - return $this->singularToPlural($left) . '_' . $this->singularToPlural($right); - } + $pieces = array_map($this->singularToPlural(...), explode('_', $this->realProperty($name))); - private function pluralToSingular(string $name): string - { - return $this->applyFirstMatch($name, [ - '/^(.+)ies$/' => '$1y', - '/^(.+)s$/' => '$1', - ]); + return implode('_', $pieces); } private function singularToPlural(string $name): string diff --git a/src/Styles/Standard.php b/src/Styles/Standard.php index 1997c29..1a6aac3 100644 --- a/src/Styles/Standard.php +++ b/src/Styles/Standard.php @@ -27,6 +27,11 @@ public function styledName(string $name): string return ucfirst($this->separatorToCamelCase($name, '_')); } + public function realName(string $name): string + { + return $this->realProperty($name); + } + public function identifier(string $name): string { return 'id'; @@ -34,12 +39,12 @@ public function identifier(string $name): string public function remoteIdentifier(string $name): string { - return $name . '_id'; + return $this->realProperty($name) . '_id'; } public function composed(string $left, string $right): string { - return $left . '_' . $right; + return $this->realName($left) . '_' . $this->realName($right); } public function isRemoteIdentifier(string $name): bool diff --git a/src/Styles/Stylable.php b/src/Styles/Stylable.php index c44f94a..bd56462 100644 --- a/src/Styles/Stylable.php +++ b/src/Styles/Stylable.php @@ -4,21 +4,39 @@ namespace Respect\Data\Styles; +/** + * Maps between PHP-side identifiers (scope names, properties) and DB-side + * identifiers (tables, columns). The naming follows a quadrant: `styled*` + * produces a PHP identifier, `real*` produces a DB identifier; `*Name` works at + * the entity level (class/table), `*Property` at the field level + * (property/column). + */ interface Stylable { - public function styledName(string $entityName): string; + /** Scope name → entity class short name (`postTag` → `PostTag`). */ + public function styledName(string $name): string; + /** Scope name → DB table name (`postTag` → `post_tag`, or `posts` under Plural). */ + public function realName(string $name): string; + + /** DB column → PHP property (`post_id` → `postId`). */ public function styledProperty(string $name): string; + /** PHP property → DB column (`postId` → `post_id`). */ public function realProperty(string $name): string; + /** Scope name → primary key column. */ public function identifier(string $name): string; + /** Scope name → foreign key column (`post` → `post_id`). */ public function remoteIdentifier(string $name): string; + /** Is this column a foreign key? */ public function isRemoteIdentifier(string $name): bool; + /** Foreign key column → relation scope name (`post_id` → `post`), or null. */ public function relationProperty(string $remoteIdentifierField): string|null; + /** Two scope names → junction table name (`post`, `tag` → `post_tag`). */ public function composed(string $left, string $right): string; } diff --git a/tests/InMemoryMapper.php b/tests/InMemoryMapper.php index 99ecb3f..1190a91 100644 --- a/tests/InMemoryMapper.php +++ b/tests/InMemoryMapper.php @@ -31,7 +31,7 @@ public function fetch(Scope $scope, mixed $extra = null): mixed } } - $row = $this->findRow((string) $scope->name, $scope->filter); + $row = $this->findRow($this->style->realName((string) $scope->name), $scope->filter); return $row !== null ? $this->hydrateRow($row, $scope) : false; } @@ -39,7 +39,7 @@ public function fetch(Scope $scope, mixed $extra = null): mixed /** @return array */ public function fetchAll(Scope $scope, mixed $extra = null): array { - $rows = $this->findRows((string) $scope->name, $scope->filter); + $rows = $this->findRows($this->style->realName((string) $scope->name), $scope->filter); $result = []; foreach ($rows as $row) { @@ -59,7 +59,7 @@ public function flush(): void foreach ($this->pending as $entity) { $op = $this->pending[$entity]; $scope = $this->tracked[$entity]; - $tableName = (string) $scope->name; + $tableName = $this->style->realName((string) $scope->name); $id = $this->style->identifier($tableName); match ($op) { @@ -166,7 +166,7 @@ private function attachChild(array &$parentRow, Scope $child): void } $id = $this->style->identifier($childName); - $childRow = $this->findRowById($childName, $id, $refValue); + $childRow = $this->findRowById($this->style->realName($childName), $id, $refValue); if ($childRow === null) { return; diff --git a/tests/Styles/AbstractStyleTest.php b/tests/Styles/AbstractStyleTest.php index 072bfd6..989a30e 100644 --- a/tests/Styles/AbstractStyleTest.php +++ b/tests/Styles/AbstractStyleTest.php @@ -32,6 +32,11 @@ public function styledName(string $name): string return $name; } + public function realName(string $name): string + { + return $name; + } + public function identifier(string $name): string { return 'id'; diff --git a/tests/Styles/Plural/PluralIntegrationTest.php b/tests/Styles/Plural/PluralIntegrationTest.php index e434164..bad13d4 100644 --- a/tests/Styles/Plural/PluralIntegrationTest.php +++ b/tests/Styles/Plural/PluralIntegrationTest.php @@ -12,6 +12,8 @@ use Respect\Data\InMemoryMapper; use Respect\Data\Styles\Plural; +use function is_object; + #[CoversClass(Plural::class)] class PluralIntegrationTest extends TestCase { @@ -47,10 +49,25 @@ protected function setUp(): void } #[Test] - public function fetchAndPersistRoundTrip(): void + public function fetchSingularScopeResolvesToPluralTable(): void { - $entity = $this->mapper->fetch($this->mapper->posts()); + // Scope name is PHP-conventional singular `post`; the Plural style + // resolves it to the `posts` seed table via realName(). + $entity = $this->mapper->fetch($this->mapper->post()); $this->assertIsObject($entity); $this->assertEquals('Post Title', $this->mapper->entityFactory->get($entity, 'title')); } + + #[Test] + public function fetchNestedRelationResolvesEachTable(): void + { + // post (→ posts) nesting author (→ authors): realName() drives both + // table lookups while the scope names stay singular. + $post = $this->mapper->fetch($this->mapper->post([$this->mapper->author()])); + $this->assertIsObject($post); + + $author = $this->mapper->entityFactory->get($post, 'author'); + $this->assertTrue(is_object($author)); + $this->assertEquals('Author 1', $this->mapper->entityFactory->get($author, 'name')); + } } diff --git a/tests/Styles/PluralTest.php b/tests/Styles/PluralTest.php index f610b9b..a36006c 100644 --- a/tests/Styles/PluralTest.php +++ b/tests/Styles/PluralTest.php @@ -18,15 +18,19 @@ protected function setUp(): void $this->style = new Plural(); } - /** @return array> */ - public static function tableEntityProvider(): array + /** + * Scope name (PHP, singular camelCase) → entity class + DB table. + * + * @return array> + */ + public static function scopeEntityTableProvider(): array { return [ - ['posts', 'Post'], - ['comments', 'Comment'], - ['categories', 'Category'], - ['posts_categories', 'PostCategory'], - ['posts_tags', 'PostTag'], + ['post', 'Post', 'posts'], + ['comment', 'Comment', 'comments'], + ['category', 'Category', 'categories'], + ['postCategory', 'PostCategory', 'posts_categories'], + ['postTag', 'PostTag', 'posts_tags'], ]; } @@ -52,22 +56,27 @@ public static function columnsPropertyProvider(): array ]; } - /** @return array> */ + /** + * Scope name (PHP, singular camelCase) → foreign key column. + * + * @return array> + */ public static function foreignProvider(): array { return [ - ['posts', 'post_id'], - ['authors', 'author_id'], - ['tags', 'tag_id'], - ['users', 'user_id'], + ['post', 'post_id'], + ['author', 'author_id'], + ['tag', 'tag_id'], + ['user', 'user_id'], ]; } - #[DataProvider('tableEntityProvider')] - public function testTableAndEntitiesMethods(string $table, string $entity): void + #[DataProvider('scopeEntityTableProvider')] + public function testScopeResolvesToEntityClassAndTable(string $scope, string $entity, string $table): void { - $this->assertEquals($entity, $this->style->styledName($table)); - $this->assertEquals('id', $this->style->identifier($table)); + $this->assertEquals($entity, $this->style->styledName($scope)); + $this->assertEquals($table, $this->style->realName($scope)); + $this->assertEquals('id', $this->style->identifier($scope)); } #[DataProvider('columnsPropertyProvider')] @@ -85,9 +94,9 @@ public function testTableFromLeftRightTable(string $left, string $right, string } #[DataProvider('foreignProvider')] - public function testForeign(string $table, string $foreign): void + public function testForeign(string $scope, string $foreign): void { $this->assertTrue($this->style->isRemoteIdentifier($foreign)); - $this->assertEquals($foreign, $this->style->remoteIdentifier($table)); + $this->assertEquals($foreign, $this->style->remoteIdentifier($scope)); } }