From 70d7fe653f598353f5540939f4065b8af0998848 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Sat, 27 Jun 2026 09:48:16 -0300 Subject: [PATCH] Add Style::realName so scope names convert to tables at the SQL boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mapper DSL used scope/method names verbatim as DB table names, forcing PHP-side code to submit to DB conventions (snake_case, plural) — the very thing Styles exist to prevent. The one identifier a Style was never consulted for was the table name. Add `Stylable::realName(scope): table`, the missing symmetric partner of `styledName(scope): class`, completing the quadrant (styled*->PHP, real*->DB; *Name->entity, *Property->field). Standard delegates it to realProperty; remoteIdentifier and composed are now expressed through realProperty/realName so they convert their inputs too. InMemoryMapper routes table lookups through realName while keeping scope names as aliases. Because realName is the single point of difference for pluralized tables, Plural collapses from three overrides to one (realName); class resolution, foreign keys, and junction names are now inherited from Standard unchanged. All edits are no-ops on existing snake/plural names; scope-name flips live in the consuming projects. --- src/Styles/Plural.php | 31 ++++--------- src/Styles/Standard.php | 9 +++- src/Styles/Stylable.php | 20 ++++++++- tests/InMemoryMapper.php | 8 ++-- tests/Styles/AbstractStyleTest.php | 5 +++ tests/Styles/Plural/PluralIntegrationTest.php | 21 ++++++++- tests/Styles/PluralTest.php | 45 +++++++++++-------- 7 files changed, 90 insertions(+), 49 deletions(-) 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)); } }