diff --git a/src/Hydrators/FlatNum.php b/src/Hydrators/FlatNum.php deleted file mode 100644 index 0218692..0000000 --- a/src/Hydrators/FlatNum.php +++ /dev/null @@ -1,55 +0,0 @@ -> */ - private array $metaCache = []; - - public function __construct( - private readonly PDOStatement $statement, - ) { - } - - protected function resolveColumnName(mixed $reference, mixed $raw): string - { - return $this->columnMeta($reference)['name']; - } - - protected function isEntityBoundary(mixed $col, mixed $raw): bool - { - if (!is_int($col) || $col <= 0) { - return false; - } - - $currentTable = $this->columnMeta($col)['table'] ?? ''; - $previousTable = $this->columnMeta($col - 1)['table'] ?? ''; - - return $currentTable !== '' && $previousTable !== '' && $currentTable !== $previousTable; - } - - /** @return array */ - private function columnMeta(int $col): array - { - if (isset($this->metaCache[$col])) { - return $this->metaCache[$col]; - } - - $meta = $this->statement->getColumnMeta($col); - if ($meta === false) { - throw new RuntimeException('PDO driver does not support getColumnMeta() for column ' . $col); - } - - return $this->metaCache[$col] = $meta; - } -} diff --git a/src/Mapper.php b/src/Mapper.php index 4411f24..862e10a 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -14,12 +14,11 @@ use Respect\Data\Collections\Filtered; use Respect\Data\EntityFactory; use Respect\Data\Hydrator; -use Respect\Relational\Hydrators\FlatNum; +use Respect\Data\Hydrators\PrestyledAssoc; use SplObjectStorage; use Throwable; use function array_keys; -use function array_merge; use function array_push; use function array_values; use function is_array; @@ -32,8 +31,6 @@ final class Mapper extends AbstractMapper { public readonly Db $db; - private PDOStatement $lastStatement; - /** @var SplObjectStorage */ private SplObjectStorage $persisting; @@ -133,7 +130,7 @@ public function flush(): void protected function defaultHydrator(Collection $collection): Hydrator { - return new FlatNum($this->lastStatement); + return new PrestyledAssoc(); } /** Resolve related entity from relation property or FK field */ @@ -374,48 +371,53 @@ private function buildSelectStatement(Sql $sql, array $collections): Sql { $selectTable = []; foreach ($collections as $tableSpecifier => $c) { - if ($c instanceof Composite) { - foreach ($c->compositions as $composition => $columns) { - foreach ($columns as $col) { - $selectTable[] = $tableSpecifier . '_comp' . $composition . '.' . $col; - } - } - } - if ($c instanceof Filtered) { $filters = $c->filters; if ($filters) { - $pkName = $tableSpecifier . '.' . - $this->style->identifier($c->name); - - if ($c->identifierOnly) { - $selectColumns = [$pkName]; - } else { - $selectColumns = [ - $tableSpecifier . '.' . - $this->style->identifier($c->name), - ]; + $fields = $this->entityFactory->enumerateFields($c->name); + $pk = $this->style->identifier($c->name); + $selectTable[] = self::aliasedColumn($tableSpecifier, $pk, $fields[$pk] ?? $pk); + + if (!$c->identifierOnly) { foreach ($filters as $f) { - $selectColumns[] = $tableSpecifier . '.' . $f; + $selectTable[] = self::aliasedColumn($tableSpecifier, $f, $fields[$f] ?? $f); } } $nextName = $c->next?->name; if ($nextName !== null) { - $selectColumns[] = $tableSpecifier . '.' . - $this->style->remoteIdentifier($nextName); + $fk = $this->style->remoteIdentifier($nextName); + $selectTable[] = self::aliasedColumn($tableSpecifier, $fk, $fields[$fk] ?? $fk); } - - $selectTable = array_merge($selectTable, $selectColumns); } } else { - $selectTable[] = $tableSpecifier . '.*'; + foreach ($this->entityFactory->enumerateFields($c->name) as $dbCol => $styledProp) { + $selectTable[] = self::aliasedColumn($tableSpecifier, $dbCol, $styledProp); + } + } + + // Composition columns come after entity columns so they override on collision + if (!$c instanceof Composite) { + continue; + } + + foreach ($c->compositions as $composition => $columns) { + $compPrefix = $tableSpecifier . Composite::COMPOSITION_MARKER . $composition; + foreach ($columns as $col) { + $selectTable[] = self::aliasedColumn($compPrefix, $col, $col); + } } } return $sql->select(...$selectTable); } + /** @return array Alias array for Sql::select() */ + private static function aliasedColumn(string $specifier, string $dbCol, string $prop): array + { + return [$specifier . '__' . $prop => $specifier . '.' . $dbCol]; + } + /** @param array $collections */ private function buildTables(Sql $sql, array $collections): Sql { @@ -466,7 +468,7 @@ private function parseCompositions(Sql $sql, Collection $collection, string $ent } foreach (array_keys($collection->compositions) as $comp) { - $alias = $entity . '_comp' . $comp; + $alias = $entity . Composite::COMPOSITION_MARKER . $comp; $sql->innerJoin($comp); $sql->as($alias); $sql->on([ @@ -569,9 +571,8 @@ private function parseHydrated(SplObjectStorage $hydrated): object /** @return SplObjectStorage|false */ private function fetchHydrated(Collection $collection, PDOStatement $statement): SplObjectStorage|false { - $this->lastStatement = $statement; $hydrator = $this->resolveHydrator($collection); - $row = $statement->fetch(PDO::FETCH_NUM); + $row = $statement->fetch(PDO::FETCH_ASSOC); return $hydrator->hydrate($row, $collection, $this->entityFactory); } @@ -586,7 +587,7 @@ private function createStatement( $query->concat($withExtra); } - $statement = $this->db->prepare((string) $query, PDO::FETCH_NUM); + $statement = $this->db->prepare((string) $query, PDO::FETCH_ASSOC); $statement->execute($query->params); return $statement; diff --git a/src/Sql.php b/src/Sql.php index 3483140..a8a6e16 100644 --- a/src/Sql.php +++ b/src/Sql.php @@ -24,8 +24,11 @@ /** Fluent SQL builder with shape-based argument detection */ class Sql { - /** Instructions where assoc array values are raw identifiers, not parameterized */ - private const array RAW = ['on', 'select']; + /** Instructions where a single assoc-array arg means raw `key = value` pairs */ + private const array RAW_PAIRS = ['on']; + + /** Instructions where args are always comma-listed (aliases, not pairs) */ + private const array COMMA_ONLY = ['select']; /** * Operators that expand an array value into multiple placeholders. @@ -343,6 +346,10 @@ public function __call(string $name, array $args): static return $this; } + if (in_array($name, self::COMMA_ONLY)) { + return $this->commaList(...$args); + } + if (!is_array($args[0])) { if (count($args) > 1 && is_array($args[1]) && array_is_list($args[1])) { return $this->namedList((string) $args[0], $args[1]); @@ -352,7 +359,7 @@ public function __call(string $name, array $args): static } if (!array_is_list($args[0])) { - if (in_array($name, self::RAW)) { + if (in_array($name, self::RAW_PAIRS)) { return $this->rawPairs($args[0]); } diff --git a/tests/Hydrators/FlatNumTest.php b/tests/Hydrators/FlatNumTest.php deleted file mode 100644 index 8c755cd..0000000 --- a/tests/Hydrators/FlatNumTest.php +++ /dev/null @@ -1,118 +0,0 @@ -pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo->exec('CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT)'); - $this->pdo->exec('CREATE TABLE post (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER)'); - $this->pdo->exec("INSERT INTO author VALUES (1, 'Alice')"); - $this->pdo->exec("INSERT INTO post VALUES (10, 'Hello', 1)"); - $this->factory = new EntityFactory(entityNamespace: 'Respect\\Relational\\'); - } - - #[Test] - public function hydrateSingleEntityFromNumericRow(): void - { - $stmt = $this->pdo->prepare('SELECT id, name FROM author'); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_NUM); - - $hydrator = new FlatNum($stmt); - $collection = Collection::author(); - $result = $hydrator->hydrate($row, $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(1, $result); - $result->rewind(); - $entity = $result->current(); - $this->assertEquals(1, $this->factory->get($entity, 'id')); - $this->assertEquals('Alice', $this->factory->get($entity, 'name')); - } - - #[Test] - public function hydrateMultipleEntitiesFromJoinedRow(): void - { - $stmt = $this->pdo->prepare( - 'SELECT author.id, author.name, post.id, post.title, post.author_id' - . ' FROM author INNER JOIN post ON post.author_id = author.id', - ); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_NUM); - - $hydrator = new FlatNum($stmt); - $collection = Collection::author()->post; - $result = $hydrator->hydrate($row, $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(2, $result); - } - - #[Test] - public function hydrateReturnsFalseForEmptyResult(): void - { - $stmt = $this->pdo->prepare('SELECT id, name FROM author WHERE id = 999'); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_NUM); - - $hydrator = new FlatNum($stmt); - - $collection = Collection::author(); - $this->assertFalse($hydrator->hydrate($row, $collection, $this->factory)); - } - - #[Test] - public function hydrateResolvesTypedEntity(): void - { - $this->pdo->exec('CREATE TABLE issue (id INTEGER PRIMARY KEY, title TEXT, type TEXT)'); - $this->pdo->exec("INSERT INTO issue VALUES (1, 'Bug Report', 'Bug')"); - - $stmt = $this->pdo->prepare('SELECT id, title, type FROM issue'); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_NUM); - - $hydrator = new FlatNum($stmt); - $collection = Typed::issue('type'); - $result = $hydrator->hydrate($row, $collection, $this->factory); - - $this->assertNotFalse($result); - $result->rewind(); - $this->assertInstanceOf(Bug::class, $result->current()); - } - - #[Test] - public function columnMetaThrowsWhenDriverDoesNotSupport(): void - { - $stmt = $this->createStub(PDOStatement::class); - $stmt->method('getColumnMeta')->willReturn(false); - - $hydrator = new FlatNum($stmt); - $collection = Collection::author(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('PDO driver does not support getColumnMeta()'); - $hydrator->hydrate([1, 'Alice'], $collection, $this->factory); - } -} diff --git a/tests/SqlTest.php b/tests/SqlTest.php index b1b80e4..773a46b 100644 --- a/tests/SqlTest.php +++ b/tests/SqlTest.php @@ -66,6 +66,29 @@ public function testSelectUsingAliasedColumns(): void $this->assertEmpty($this->object->params); } + public function testSelectWithAllAliasedColumns(): void + { + $sql = (string) $this->object->select( + ['t__id' => 't.id'], + ['t__name' => 't.name'], + )->from('t'); + $this->assertEquals( + 'SELECT t.id AS t__id, t.name AS t__name FROM t', + $sql, + ); + } + + public function testSelectWithSingleAliasedColumn(): void + { + $sql = (string) $this->object->select( + ['t__id' => 't.id'], + )->from('t'); + $this->assertEquals( + 'SELECT t.id AS t__id FROM t', + $sql, + ); + } + public function testSelectWithAggregateFunctions(): void { $sql = (string) $this->object->select('column', 'COUNT(column)', 'SUM(amount)')