Skip to main content

Dynamic Search Is Not One WHERE Clause: TeaQL vs Conventional Rust

· 14 min read
Philip Z
Architect

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 more UI;
  • 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 typeSearchable fields
Rootorder_number, order_date
Parent relationstatus.code, status.name, customer.name
Child relationorder_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:

ScenarioMatching ordersSemantic diff
Empty filter600 bytes
Order number110 bytes
Status code150 bytes
Order date50 bytes
Status name150 bytes
Child product name110 bytes
Date + status + child product20 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:

FileWhat the reviewer must verify
Result contractTyped rows, facets, previews, validation, and wire shape
repository.rsDynamic-field mapping, three SQL paths, parameters, row mapping, and aggregation
context.rsUser, purpose, SQL trace, result summary, and elapsed time
error.rsDatabase, conversion, validation, and unknown-field failures
main.rsAssembly, 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 surfaceTeaQLConventional RustReduction
Data-access implementation3118883.5%

The secondary comparison counts all handwritten runtime code used by the executable benchmark:

TeaQL runtime surfaceLOC
Query file36
Demo entry point with direct WebResponse25
Total61
Conventional runtime surfaceLOC
Strong result contract100
Request context and trace67
Error model45
Repository, SQL, and mapping211
Demo entry point20
Total443

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.

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.