Skip to main content

One post tagged with "sql"

View All Tags

Generated Query APIs vs Handwritten SQL

· 5 min read
TeaQL Code Gen
Core Contributor

TeaQL generates query APIs such as Q::platforms() and Q::merchants(). That immediately raises a fair question: why not just write SQL?

The short answer is that TeaQL is not trying to replace handwritten SQL everywhere. Generated query APIs are useful when the repeated work is not the SQL text itself, but the domain semantics around the SQL: field names, relation names, relation loading, statistics, validation, JSON search, and graph-shaped results.

The Handwritten SQL Case

There are many cases where handwritten SQL is the right tool:

  • a query is performance-critical and carefully tuned;
  • the shape is reporting-specific rather than domain-object-specific;
  • the team wants exact control over joins, hints, indexes, and execution plans;
  • the service is small enough that generated APIs would add more weight than value.

TeaQL should not get in the way of those cases. The Rust runtime keeps a generic query AST and SQL compiler, but the design does not require every query to be expressed through generated methods.

If explicit SQL is the clearest abstraction, use explicit SQL.

The Generated API Case

Generated APIs become useful when a large domain model produces many ordinary queries with the same repeated structure:

let merchants = Q::merchants()
.select_name()
.select_platform_with(Q::platforms().select_name())
.which_names_contain("tea")
.order_by_create_time_desc()
.page(1, 20)
.execute_for_list(&ctx)
.await?;

This is not shorter than SQL in every case. The value is that the query is tied to the generated domain model:

  • fields are exposed as methods;
  • object relations are selected by relation name, not storage column name;
  • readable filters follow the model vocabulary;
  • subqueries can be constructed from the target entity's own generated API;
  • execution returns typed entities or typed SmartList<T> collections.

That matters when the model is large and the same entity relationships appear across many services, pages, and business workflows.

Relation Semantics

A generated API can encode relation semantics that plain SQL does not name.

For example:

let platforms = Q::platforms()
.select_merchant_list_with(
Q::merchants()
.select_name()
.select_platform(),
)
.execute_for_list(&ctx)
.await?;

The application is not just asking for a join. It is asking for a platform list where each platform contains a merchant_list, and each merchant can carry its reverse platform relation when selected. That object shape is a domain result, not only a SQL result set.

The runtime still compiles to SQL and fetches records. The generated API gives the caller a domain-oriented way to request the shape.

Subqueries Without Stringly-Typed Entity Names

TeaQL still has SelectQuery internally. Application code can use it directly, but generated crates should prefer the entity-specific Q entrypoints:

let platforms = Q::platforms()
.select_merchant_list_with(
Q::merchants()
.which_names_are("TeaQL Merchant"),
)
.execute_for_list(&ctx)
.await?;

This keeps the application out of string-based construction such as SelectQuery::new("Merchant") for ordinary code. The generic query type remains available for runtime and escape-hatch scenarios.

Statistics

Large business systems rarely stop at list queries. They also need counts, sums, grouped aggregates, relation counts, and derived summary rows.

A generated API can give those operations domain names:

let platforms = Q::platforms()
.select_name()
.count_merchant_list_as("merchant_count")
.execute_for_list(&ctx)
.await?;

The runtime can attach relation aggregate results back to parent rows, while the generated request builder keeps the caller focused on domain concepts.

This does not remove the need for handwritten reporting SQL. It gives ordinary domain statistics a consistent path.

Why Keep a Generic Runtime?

If generated APIs are useful, why not make the whole runtime typed?

Because TeaQL still needs dynamic behavior:

  • generated service crates vary by domain model;
  • aggregate rows can contain dynamic projection fields;
  • JSON-expression search and raw SQL escape hatches need flexible query metadata;
  • graph planning operates across entity types;
  • relation enhancement starts with records and then maps into typed entities.

The generated layer is typed for application ergonomics. The runtime layer is generic so it can plan, compile, enhance, validate, and execute across many generated models.

Where Generated APIs Hurt

Generated code is not free:

  • it increases compile time;
  • it creates large diffs when templates change;
  • it can hide SQL details if developers treat it as magic;
  • it needs strong generated-crate tests to catch template regressions.

TeaQL tries to offset that with explicit generated code, local runtime crates, and generated service tests that run real SQLite scenarios. The code is not hidden behind a server or reflection system; it is Rust source that can be inspected.

Still, generated APIs should be used where they buy something. A small service with ten queries may be better served by direct sqlx.

A Practical Boundary

The boundary I use is this:

  • if the query expresses a domain object shape, relation graph, validation path, or repeated business filter, prefer the generated Q API;
  • if the query expresses a one-off report, hand-tuned performance path, or database-specific operation, prefer handwritten SQL;
  • if generated code starts hiding a critical execution detail, drop down a level.

TeaQL's design only works if the lower level remains available.

The Real Goal

The goal is not to win an argument against SQL. SQL remains the execution language and often the best authoring language.

The goal is to avoid making every application workflow manually rediscover the same domain relationships. In a large model, the generated API becomes a shared vocabulary: Q::platforms(), select_merchant_list_with(...), have_platform(), count_merchant_list_as(...), and so on.

That vocabulary is where TeaQL is useful. Not because SQL is bad, but because large domain models need more than SQL strings to stay coherent.