Background
#85 introduced PostQueryInterface / PostQueryContext for DML results. While reviewing, it became clear that the same mechanism could generalise beyond DML — SELECT results, collection wrapping, and entity composition could all ride on the same dispatch. This issue captures that direction as a memo, not committed work.
The insight
Today DbQueryInterceptor has two paths:
- Return type implements
PostQueryInterface → call execPostQuery → delegate to the class's static factory.
- Otherwise → classic path: fetch rows, hydrate into entity / array, return.
If any return type that implements PostQueryInterface — including SELECT-facing wrapper classes — goes through path 1, we end up with a single extension mechanism. The implementing class owns its own construction; the framework doesn't need to branch.
Shape of the unified design
The implementing class decides what it is — single entity, collection wrapper, or DML result. Each form looks the same from the framework's side: one static factory, one context.
// DML: rowcount for UPDATE / DELETE
final class AffectedRows implements PostQueryInterface { /* uses \$context->statement->rowCount() */ }
// DML: resolved params + insert id for INSERT
final class InsertedRow implements PostQueryInterface { /* uses \$context->values, \$context->pdo->lastInsertId() */ }
// SELECT: collection wrapper around hydrated entities
final class Articles implements PostQueryInterface
{
public function __construct(public readonly Collection \$collection) {}
public static function postQuery(PostQueryContext \$context): static
{
return new static(new Collection(\$context->rows)); // rows already hydrated as array<Article>
}
}
// SELECT: entity that also wants framework metadata
final class Article implements PostQueryInterface
{
// ...fields plus meta like \$sourceSql, \$boundValues, etc.
public static function postQuery(PostQueryContext \$context): static { ... }
}
Crucial detail: the framework owns hydration, not the factory
A naive design would hand the factory the raw PDOStatement and expect it to fetchAll(PDO::FETCH_ASSOC) itself. That breaks down fast:
- DI-backed entity hydration (BEAR-style resources, constructor injection, factories resolved via Injector) needs the
FetchInterface / FetchFactory pipeline and the container. Pushing that into PostQueryInterface implementations drags Injector into the context and forces every result class to reimplement hydration.
- Different fetch modes (
FETCH_ASSOC vs object vs factory-driven) are already handled by the existing pipeline.
The clean split is:
- Framework / interceptor /
perform() — run the existing FetchInterface + FetchFactory + Injector pipeline and produce array<Article> (or whatever hydrated form the pipeline supports today).
PostQueryInterface implementation — receives the hydrated result via the context and composes it (wraps in a Collection, adds metadata, selects a single element, etc.). It never touches raw rows or DI.
So PostQueryContext grows one field:
final class PostQueryContext
{
public function __construct(
public readonly PDOStatement \$statement,
public readonly ExtendedPdoInterface \$pdo,
public readonly array \$values,
public readonly array \$rows = [], // hydrated entities for SELECT paths, empty for DML
) {}
}
What about static return type?
postQuery(): static means a class can only return itself. An Article cannot return new LaravelCollection(...) from its factory — type mismatch.
The correct pattern is composition, not inheritance or substitution: write a dedicated class (Articles) that implements PostQueryInterface and holds a Collection as a property.
\$articles = \$repo->list(); // returns Articles
\$articles->collection->map(...)->filter(...); // access the underlying Collection
Benefits over extends Collection:
- Free choice of Collection library (Laravel / illuminate / doctrine / custom).
- Other metadata can live alongside on the same class (
\$articles->executedSql, \$articles->totalCount, etc.).
- No multiple-inheritance tension.
Disposition
Still speculative — the current SELECT side (FetchInterface + entity hydration) covers real use cases already. Defer until a concrete request for SELECT-side composition or metadata surfaces. When it does, the existing PostQueryInterface + PostQueryContext vocabulary is already the right shape; the change is small:
- Extend
PostQueryContext with \$rows (hydrated entities).
- Extend
DbQueryInterceptor so SELECT-side return types implementing PostQueryInterface route through the same path, with hydration done before the factory call.
- No new interface, no
FetchContext sibling — same vocabulary covers both axes.
Refs #85
Background
#85 introduced
PostQueryInterface/PostQueryContextfor DML results. While reviewing, it became clear that the same mechanism could generalise beyond DML — SELECT results, collection wrapping, and entity composition could all ride on the same dispatch. This issue captures that direction as a memo, not committed work.The insight
Today
DbQueryInterceptorhas two paths:PostQueryInterface→ callexecPostQuery→ delegate to the class's static factory.If any return type that implements
PostQueryInterface— including SELECT-facing wrapper classes — goes through path 1, we end up with a single extension mechanism. The implementing class owns its own construction; the framework doesn't need to branch.Shape of the unified design
The implementing class decides what it is — single entity, collection wrapper, or DML result. Each form looks the same from the framework's side: one static factory, one context.
Crucial detail: the framework owns hydration, not the factory
A naive design would hand the factory the raw
PDOStatementand expect it tofetchAll(PDO::FETCH_ASSOC)itself. That breaks down fast:FetchInterface/FetchFactorypipeline and the container. Pushing that intoPostQueryInterfaceimplementations drags Injector into the context and forces every result class to reimplement hydration.FETCH_ASSOCvs object vs factory-driven) are already handled by the existing pipeline.The clean split is:
perform()— run the existingFetchInterface+FetchFactory+ Injector pipeline and producearray<Article>(or whatever hydrated form the pipeline supports today).PostQueryInterfaceimplementation — receives the hydrated result via the context and composes it (wraps in a Collection, adds metadata, selects a single element, etc.). It never touches raw rows or DI.So
PostQueryContextgrows one field:What about
staticreturn type?postQuery(): staticmeans a class can only return itself. AnArticlecannotreturn new LaravelCollection(...)from its factory — type mismatch.The correct pattern is composition, not inheritance or substitution: write a dedicated class (
Articles) that implementsPostQueryInterfaceand holds a Collection as a property.Benefits over
extends Collection:\$articles->executedSql,\$articles->totalCount, etc.).Disposition
Still speculative — the current SELECT side (
FetchInterface+ entity hydration) covers real use cases already. Defer until a concrete request for SELECT-side composition or metadata surfaces. When it does, the existingPostQueryInterface+PostQueryContextvocabulary is already the right shape; the change is small:PostQueryContextwith\$rows(hydrated entities).DbQueryInterceptorso SELECT-side return types implementingPostQueryInterfaceroute through the same path, with hydration done before the factory call.FetchContextsibling — same vocabulary covers both axes.Refs #85