Dynamic Search Is Not One WHERE Clause: TeaQL vs Conventional Rust
A dynamic order page sounds ordinary: search, paginate, show status cards, and preview a few products. The interesting part begins when we ask what a production-grade implementation must actually contain—and count all of it.
We built the same read model with TeaQL Rust and conventional Rust plus SQLite. The first comparison was wrong in three important ways. Correcting those errors made the result more useful than the original benchmark.
The Page We Measured
The page is an order management center with the following backend behavior:
- dynamically filter orders using fields selected at runtime;
- support root fields, parent-relation paths, and child-relation paths;
- return a stable paginated order list and the complete matching count;
- calculate four status facets from the same active filter;
- include order number, order date, status, and product previews;
- include the complete line-item count for a
+N moreUI; - execute through a user context with an explicit business purpose;
- leave SQL, result, elapsed-time, user, and purpose evidence;
- return a typed native result rather than assembling unvalidated JSON.
Both implementations used the same deterministic SQLite fixture. The verified dataset contains 60 orders, evenly distributed across four statuses.
Why the First Comparison Was Wrong
Benchmarks become persuasive by removing advantages, not by adding them. We changed the comparison three times after reviewing its assumptions.
Correction 1: Conventional JSON Is Not a Production Result
The first conventional implementation created serde_json::Value objects
inside SQLite row callbacks. It was executable, but it had no strong result
types, status enum, repository boundary, checked conversions, or structured
error model.
That understated the conventional implementation. We replaced it with a strong
OrderPage result containing typed rows, product previews, status facets,
dates, counts, and validation. We also added a repository boundary, request
context, purpose-aware SQL evidence, result summaries, and elapsed time.
Correction 2: The Domain Model Is Not a Page Cost
TeaQL's 74-line model is excluded from the comparison.
That model is not disposable code written only for this page. Without TeaQL, the same domain still needs to be described in requirements, an ER model, schemas, API documentation, entities, or another durable specification. With TeaQL, the model is also reused to generate APIs, documentation, runtime metadata, and implementations in multiple languages.
Charging the whole model to one TeaQL page would count a reusable architectural asset as a one-off feature cost.
Correction 3: TeaQL Does Not Need a Handwritten Presenter
We initially wrote a 96-line Presenter that converted generated TeaQL entities
into the conventional OrderPage shape. That made byte-for-byte response
comparison easy, but it forced TeaQL to pay for an abstraction required only by
the conventional implementation.
The TeaQL runtime already serializes a generated SmartList<T> directly:
let page = load_order_page(&ctx, &request).await?;
let response = WebResponse::from_smart_list(page);
The Presenter was deleted. TeaQL now returns its native generated entity graph,
record count, dynamic aggregates, and facets. Conventional Rust returns its
handwritten strong OrderPage.
The two native wire shapes are intentionally allowed to differ. A test-only jq normalizer extracts the business fields needed for semantic parity. That normalizer is evidence tooling, not application code, and is excluded from both sides.
This correction also changed the contract accounting. Only the 79-line input request contract is shared and excluded. The 100-line conventional result contract is now counted only on the conventional side.
The TeaQL Query
The measured TeaQL data-access implementation is 31 nonblank handwritten lines:
fn line_items_for_present() -> OrderLineRequest {
Q::order_lines_minimal()
.comment("Select product previews for each order")
.select_product_name()
.select_image_url()
}
fn order_statuses_for_facet() -> OrderStatusRequest {
Q::order_statuses_minimal()
.select_name()
.select_code()
.count_customer_orders()
}
pub async fn load_order_page(
ctx: &ServiceRuntime,
request: &OrderPageRequest,
) -> Result<SmartList<CustomerOrder>, Box<dyn std::error::Error>> {
let filter_json = serde_json::to_string(request.filter())?;
Ok(Q::customer_orders_minimal()
.comment("Load the searchable order management page")
.select_order_number()
.select_order_date()
.select_status_with(
Q::order_statuses_minimal().select_name().select_code()
)
.filter_with_json(&filter_json)
.facet_by_status_as("order_status", order_statuses_for_facet())
.select_order_line_list_with(line_items_for_present())
.count_order_lines()
.order_by_order_number_asc()
.purpose("Render the order management center")
.execute_for_page(ctx, request.offset(), request.limit())
.await?)
}
This is not a string wrapper around one SQL statement. It asks for a typed domain result with a relation, relation aggregate, total count, facet, dynamic filter, stable order, paging, trace comments, and an execution purpose.
Composition Is the Feature
Extracting order_statuses_for_facet() is more than a cosmetic refactor. The
function returns an OrderStatusRequest: a typed query value that can be passed
into the outer CustomerOrderRequest.
fn order_statuses_for_facet() -> OrderStatusRequest {
Q::order_statuses_minimal()
.select_name()
.select_code()
.count_customer_orders()
}
Q::customer_orders_minimal()
.facet_by_status_as("order_status", order_statuses_for_facet())
The same pattern composes the product-preview request into the order request:
.select_order_line_list_with(line_items_for_present())
This is a structural difference from ordinary handwritten SQL. SQL itself has
subqueries and CTEs, but application-level SQL fragments are usually not closed
under composition: two fragments can each be valid and still become invalid or
semantically wrong when concatenated. They may introduce a second WHERE,
reuse an alias or parameter position, change grouping, apply pagination at the
wrong level, or cause the list, count, and facet paths to disagree.
Conventional code can build a composable query AST to solve this problem, but then the application or framework must provide typed nodes, relation metadata, alias management, parameter binding, aggregate semantics, and a SQL compiler. TeaQL generates that infrastructure from the domain model. The handwritten unit of reuse is therefore a domain query—not a string fragment—and invalid combinations are constrained by generated method signatures and the Rust type checker before SQL is produced.
That is why naming a subquery as a function works so naturally here. The function can be read, tested, reused, and nested without exposing SQL assembly details to the page query.
Dynamic Search Is the Expensive Part
The verified input may choose any subset of six representative fields:
| Path type | Searchable fields |
|---|---|
| Root | order_number, order_date |
| Parent relation | status.code, status.name, customer.name |
| Child relation | order_line_list.product_name |
For example:
{
"order_date": "2024-01-2",
"status.code": "PEND",
"order_line_list.product_name": "Box"
}
The generated TeaQL request layer resolves those paths from the domain model. The query above does not change when the caller selects A, B, C, or A+B+C.
The conventional fixed-whitelist implementation must explicitly repeat the mapping in the list, count, and facet queries:
AND (?1 IS NULL OR o.order_number LIKE ?1)
AND (?2 IS NULL OR o.order_date LIKE ?2)
AND (?3 IS NULL OR s.code LIKE ?3)
AND (?4 IS NULL OR s.name LIKE ?4)
AND (?5 IS NULL OR EXISTS (
SELECT 1 FROM customer_data c
WHERE c.id = o.customer
AND c.version > 0
AND c.name LIKE ?5))
AND (?6 IS NULL OR EXISTS (
SELECT 1 FROM order_line_data f
WHERE f.customer_order = o.id
AND f.version > 0
AND f.product_name LIKE ?6))
It must also keep parameter positions and typed values aligned across all three statements.
There is another legitimate conventional design: build a generic search AST, field registry, relation-path resolver, operator parser, type converter, and SQL compiler. That avoids repeated fixed SQL, but it is no longer a small page implementation. It is the beginning of model-driven query infrastructure.
What Was Executed
The acceptance harness ran seven scenarios:
| Scenario | Matching orders | Semantic diff |
|---|---|---|
| Empty filter | 60 | 0 bytes |
| Order number | 11 | 0 bytes |
| Status code | 15 | 0 bytes |
| Order date | 5 | 0 bytes |
| Status name | 15 | 0 bytes |
| Child product name | 11 | 0 bytes |
| Date + status + child product | 2 | 0 bytes |
Each semantic comparison covered rows, total count, line-item count, product preview data, remaining count, and all four status facets.
The harness also proved that:
- both typed input boundaries reject unknown fields;
- omitting TeaQL
.purpose(...)causes a Rust compile error before execution; - the standard TeaQL log buffer records user, purpose, nested trace, SQL, result summary, and elapsed time without feature-specific log statements;
- the verified TeaQL trace contains no product-master N+1 query.
TeaQL emitted seven SQL statements while the conventional implementation emitted three. That is runtime evidence, not a performance score. The benchmark does not claim that more or fewer statements alone predict latency, throughput, or database cost.
LOC Is Only Half the Review Cost
There is a second difference that a line-count table does not show: how many places a reviewer must keep in working memory.
To review the business behavior of the TeaQL page, the reviewer opens one file:
query.rs. That file shows, in one continuous expression:
- which order fields are selected;
- how status and line-item relations are projected;
- where the dynamic filter enters;
- how status facets and line-item counts are calculated;
- how results are ordered and paginated;
- the trace comments and the business purpose of execution.
The generated model vocabulary, dynamic path resolution, typed result graph, SQL generation, serialization, context propagation, and standard evidence do not need to be reconstructed from page-specific glue code. The executable demo also has a 25-line generic entry point, but it does not contain the page's business query. For a change-focused review, the business review surface is one file.
The conventional implementation is properly separated into layers, but a complete feature review crosses five handwritten files:
| File | What the reviewer must verify |
|---|---|
| Result contract | Typed rows, facets, previews, validation, and wire shape |
repository.rs | Dynamic-field mapping, three SQL paths, parameters, row mapping, and aggregation |
context.rs | User, purpose, SQL trace, result summary, and elapsed time |
error.rs | Database, conversion, validation, and unknown-field failures |
main.rs | Assembly, execution, and serialization boundary |
This is not an argument against separation of concerns. Those separations are necessary when the infrastructure is handwritten. It is an argument about review fan-out: TeaQL keeps the page-specific decision in one declarative query while generated and standard infrastructure carries the cross-cutting concerns. Conventional Rust makes those responsibilities explicit in application code, so the reviewer has to follow them across files and keep the connections consistent.
For coding agents, this locality matters as much as raw LOC. A smaller diff is useful; a diff whose meaning can be established from one file is easier to generate, inspect, and revise.
The Final Review-Surface Numbers
The primary comparison counts the handwritten data-access implementation:
| Measured surface | TeaQL | Conventional Rust | Reduction |
|---|---|---|---|
| Data-access implementation | 31 | 188 | 83.5% |
The secondary comparison counts all handwritten runtime code used by the executable benchmark:
| TeaQL runtime surface | LOC |
|---|---|
| Query file | 36 |
Demo entry point with direct WebResponse | 25 |
| Total | 61 |
| Conventional runtime surface | LOC |
|---|---|
| Strong result contract | 100 |
| Request context and trace | 67 |
| Error model | 45 |
| Repository, SQL, and mapping | 211 |
| Demo entry point | 20 |
| Total | 443 |
That is 61 versus 443 nonblank handwritten lines, an 86.2% reduction in this specific executable benchmark.
The following are excluded:
- the reusable domain model;
- the shared input request contract;
- generated TeaQL code;
- fixture generation;
- evidence exporters and semantic normalizers;
- dependencies and build output;
- blank lines and line comments.
A Real Limitation the Benchmark Found—and the Fix It Drove
The benchmark exposed a correctness gap in TeaQL Rust 4.2.5: applying
.limit(3) to a nested relation limited the child query globally, rather than
selecting three children independently for every parent order.
That means the aesthetically ideal query:
Q::order_lines_minimal()
.limit(3)
.select_product_name()
.select_image_url()
did not implement a per-order product preview correctly in the runtime used for the original run. Keeping it in the benchmark would have made the TeaQL code shorter but the result wrong, so the verified query did not use that limit.
Instead, the Rust response loads the order-line snapshots for the current page. The test/UI projection consumes the first three children of each order while the generated relation aggregate retains the complete line-item count. This preserves the required response semantics, but it is bounded over-fetch—not the ideal database execution plan.
A separate Java 1.526 probe generated the expected per-parent Top-N SQL using
ROW_NUMBER() OVER (PARTITION BY customer_order ...). That probe also found a
different gap: its generated child-count value was not attached to the returned
parent entity.
The follow-up fix was implemented on 2026-08-12 against the TeaQL Rust v4.2.7
codebase. A paged relation request now carries its reverse foreign key as a
partition key. The SQL compiler emits ROW_NUMBER() OVER (PARTITION BY ...),
applies offset and limit to each partition, and the runtime removes the internal
rank column before attaching children to their parents. The regression suite
checks two parents with three selected children each; a real in-memory SQLite
execution starts with five children for each parent and returns exactly six
rows, the top three per parent. The remote v4.2.7 run passed 171 Rust tests
across core, SQL, runtime, and the SQLite provider.
The Java child-count attachment path was fixed in the same follow-up. Its portable SQL runtime now maps grouped aggregate rows back to the returned parent entities, including a zero default when a parent has no matching child. The remote Java reactor run passed 236 tests with no failures or errors.
These fixes close the two concrete parity gaps found by the probes. The original benchmark evidence still records the behavior of the released runtime it actually tested; its bounded-over-fetch implementation should remain in that historical run until the complete benchmark is rerun against a release that contains the Rust fix.
We publish this because a benchmark should be allowed to find defects in the technology it evaluates. The limitation does not change the seven verified semantic comparisons, dynamic-filter checks, purpose compile gate, trace evidence, or review-surface measurement. It does narrow the performance claim: this benchmark proves the behavior of the page, but it does not claim that the Rust child-preview plan used in that original run was optimal.
What the Numbers Do—and Do Not—Mean
This is not a universal claim that TeaQL is 83.5% shorter than every Rust data stack. A framework with comparable model metadata and a dynamic query engine may move much of the same work out of application code. That is precisely the architectural point: the reduction comes from reusable infrastructure, not from a clever spelling of SQL.
Handwritten SQL remains the right choice for one-off reporting, database-specific optimization, or carefully tuned critical paths. The question is whether every ordinary business page should manually rebuild field mapping, relation search, counts, facets, typed assembly, context, and evidence.
Finally, fewer reviewed lines suggest fewer opportunities for coding-agent drift and lower human review effort, but LOC is not a direct Token measurement. We have not yet measured end-to-end model Token savings, so we do not convert the LOC percentages into Token claims.
Why This Matters for Coding Agents
A coding agent can generate 443 lines. The harder problem is keeping those lines coherent when requirements change:
- a new searchable relationship is added;
- a field changes type or name;
- the list, count, and facet filters must remain identical;
- user and purpose context must survive every execution path;
- the response must remain typed and reviewable.
TeaQL moves that repeated structure into a generated domain vocabulary and a runtime query engine. The handwritten code stays close to the product manager's language: orders, status, product previews, counts, filters, purpose, and page.
That is the more important result. The agent writes less code, but reviewers also inspect code expressed in the same domain model used by the rest of the system.
Source and Related Reading
- Order read-model benchmark source
- Dynamic JSON Query cookbook
- Generated Query APIs vs Handwritten SQL
- TeaQL vs MyBatis: an order page example
The verified run recorded in the benchmark is
named-status-facet-verified-20260812. The evidence bundle contains native
responses, normalized semantic responses, parity diffs, SQL traces, the
missing-purpose compiler error, LOC measurement, and SHA-256 hashes.
