SQL tag redesign plan
Proposal for a dependency-free, dialect-aware SQL fragment API for Flowblade.
Status: proposal. This page describes a possible direction, not released behavior. The current
@flowblade/sql-tagAPI remains the supported API until an implementation and migration path are released.
Goal
Replace the sql-template-tag implementation with a small Flowblade-owned
fragment builder. Preserve the familiar tagged-template composition model while
making target dialect rendering explicit and enabling future static tooling.
Keep query execution, database connections, and schema introspection outside
the SQL-tag package.
Use @typed-sql/postgres
as a source of design ideas—not as a dependency or as a scope target. Its
dialect-owned renderer, strict handling of unsupported syntax, query structure
and runtime contracts provide useful lessons. Its PostgreSQL grammar,
catalog introspection, schema inference and codecs are substantially broader
than the Flowblade tag and should not be pulled into the initial rewrite.
Proposed API
A factory creates a tag whose render target is explicit:
import { createSqlTag } from "@flowblade/sql-tag";
const sql = createSqlTag({ dialect: "postgres" });
const query = sql<{ id: number; name: string }>`
SELECT id, name
FROM ${sql.identifier("person")}
WHERE id IN (${sql.join(ids)})
${sql.if(name !== undefined, () => sql`AND name = ${name}`)}
`;
const compiled = query.compile();
// { sql: "...", values: [...] }
The generic row type remains a compile-time declaration by the caller. It does not validate database results. Database-aware type inference and runtime row validation would need schema evidence and belong in a separate analyzer or adapter feature.
The dialect option selects SQL rendering rules such as placeholders and
identifier quoting; it does not parse or validate the SQL grammar. Initial
dialects should be selected based on Flowblade adapter usage and verified
against actual drivers. PostgreSQL, DuckDB, MySQL, SQLite and SQL Server are
candidates, not a promise of immediate support for every engine/version.
Internal representation
Represent a query as an immutable sequence of literal text and typed interpolation parts rather than pre-rendered SQL strings:
- Value parts hold parameter values and are always bound, never stringified into SQL.
- Nested fragment parts compose other tagged queries without losing value order.
- Identifier parts are quoted by the selected dialect.
- Raw parts are an explicit unsafe escape hatch and remain clearly marked as trusted SQL text.
Render the complete fragment sequence only at compile(). This lets nested
fragments and helpers share one placeholder counter, avoids renumbering bugs,
and creates a natural future boundary for validation, fingerprints or
prepared-statement shape checks. It also avoids pretending that interpolated
values can safely stand for table names, column names or SQL keywords.
Safety and composition requirements
- Interpolated JavaScript values are parameters by default. Rendering errors for unsupported values should be explicit; never silently convert objects, symbols or functions into SQL source.
identifier()quotes each identifier component according to the selected dialect. A qualified name should be represented as components rather than quoted as one dotted string.raw()is documented as unsafe and reserved for trusted, application-owned SQL fragments. It must not be presented as a way to bind values.join()composes values or fragments in order. Define and test empty-array behavior so callers cannot accidentally generate malformed or broader SQL.bulk()produces a fragment with parameterized values and validates row width consistently; define empty input behavior explicitly.if()evaluates only the selected branch. The currentemptyfragment and conditional composition remain available.
These features reduce accidental injection risks but cannot make arbitrary
dynamic SQL safe. In particular, user input must not be passed to raw().
Dialect rendering
Each dialect renderer owns only the rules needed to compile fragments, at minimum:
- placeholder sequence and binding order;
- identifier quoting and escaping;
- any explicit dialect-specific rendering differences required by supported adapters.
Do not assume one placeholder style is interchangeable across drivers. Verify the rendered statement and values with each target driver. Keep rendering separate from formatting: a SQL formatter’s dialect option is not a query parameter renderer.
compile() should return one canonical result, initially proposed as:
type CompiledSql = {
readonly sql: string;
readonly values: readonly unknown[];
};
The current package exposes sql, text, statement and values through the
upstream object. A migration must document which getters remain, what dialect
they represent, and how they relate to compile(). Do not silently change
their meanings. Keep the legacy default tag and output behavior during a
deprecation period, or release a breaking major if equivalent compatibility
cannot be maintained.
Lessons from typed-sql/postgres
The following ideas can improve Flowblade without copying its entire compiler/runtime:
| typed-sql idea | Flowblade application |
|---|---|
| Dialect-owned semantics and rendering | A factory-selected renderer, independent from execution adapters. |
| Structural query representation | Immutable fragments, ordered values and one render pass for nested composition. |
| Fail-closed diagnostics | Explicit errors for malformed helper inputs and unsupported interpolation types. |
| Prepared query shape checks | A later opt-in utility can check that a query factory keeps stable SQL structure as values vary. |
| Driver-neutral core and optional adapters | Keep the tag free of Kysely, DuckDB and driver imports; let adapters consume compiled output. |
| Redacted observability | If query fingerprints or instrumentation are later added, exclude SQL values by default and keep observation opt-in. |
| Schema-aware type policy and codecs | Consider only as separate packages/features with explicit schema evidence; sql<Row> alone cannot provide these guarantees. |
@typed-sql/postgres also supports grammar-level analysis, catalog snapshots,
live verification, plan inspection, and native bulk capabilities. These are
valuable for a full typed query compiler, but are not requirements for this
small SQL construction library.
Delivery plan
Phase 1: Fragment builder
- Implement immutable literal/value/fragment/identifier/raw parts without an external SQL-template dependency.
- Preserve
sql,join,bulk,if,emptyand generic row typing. - Add
createSqlTag()with a deliberately small, documented dialect set. - Add
compile()returning SQL and ordered values. - Keep current imports and output properties working through compatibility aliases where feasible.
Phase 2: Contract and security tests
Test exact SQL and value order for basic templates, nested templates, multiple levels of composition, joins, conditionals, empty fragments and bulk rows. Test placeholder continuity across nested fragments, identifier escaping for each dialect, and rejection of invalid fragment inputs. Add driver-backed smoke coverage for every initially supported renderer, especially DuckDB, which consumes the tag today.
Phase 3: Adoption and migration
Update source-duckdb to construct or consume the DuckDB-targeted tag without
changing query semantics. Audit other consumers for assumptions about
.sql, .text and .statement. Document migration examples and deprecation
timelines, then remove sql-template-tag only after no runtime or type
dependency remains.
Later, separately scoped work
Evaluate typed table/column descriptors, schema-aware static checking, runtime codecs, query fingerprinting, and prepared-query shape validation as independent additions. Each requires its own public contract and evidence model; none should be implied by the base tag factory.
Acceptance criteria
- No runtime or declaration dependency on
sql-template-tag. - Existing composition features and adapter behavior are covered by regression tests.
- Every factory-created query renders deterministically for its declared dialect and returns values in matching placeholder order.
- Values remain parameterized; identifiers are escaped; raw SQL is explicitly unsafe.
sql<Row>is documented as a type assertion, not schema validation.- Compatibility behavior and breaking changes are explicit before release.
See the current SQL tag guide for released behavior and the
@typed-sql/postgres execution guide
for the driver-neutral execution and capability design that informed this
proposal.