Idea: Compile-time SQL validator (sqlx-style) #90
Replies: 3 comments 1 reply
-
|
What is sqlparser? It sounds like a possibly light client side SQL parser that I thought we agreed we would not do and only have Hyper do all validation using client pass through. |
Beta Was this translation helpful? Give feedback.
-
|
We won't wait for S1. Audience identification. We will do this regardless of any user case wanting it. Sqlx has been widely used in the Rust ecosystem. We will just do it. |
Beta Was this translation helpful? Give feedback.
-
Phase 0 spike results (run 2026-05-31) — all passed, architecture is viableRan the gating spikes against pinned hyperd Your comments, addressed
So the validator sends SQL straight to Hyper, and on S1 (audience gate) — removed, per your call. No longer gates the work. Empirical findings
No kill criterion triggered. Implementation is paused here for now; the plan is updated to v4 with all of the above. Feedback still welcome before Milestone A kicks off. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
TL;DR
Add an opt-in, sqlx-style compile-time SQL validation path to
hyperdb-apithat catches dropped/renamed columns, typos, and missing tables atcargo buildtime. Validation runs by spinning up an embedded Hyper instance inside the proc-macro host and dry-running the user's SQL. The existing runtime path is unchanged — compile-time checking is a strictly additive opt-in behind a cargo feature.v1 scope (descoped from v2 plan): name-based column check only. Type checking is deferred until measured demand justifies the maintenance burden.
Pre-requisite gating: Phase 0 spikes (~3 engineer-days) MUST pass before any implementation work is scheduled. Two of the spikes can kill the project.
Audience & motivation
Primary problem: Today, a typo in a SQL string (
SELECT id, ema1l FROM users) or a schema drift (column dropped, type changed) surfaces as aResult::Errat runtime, often only on a code path covered by integration tests. We want this to be acompile_error!at the developer's IDE.Honest scoping: This validates that your SQL strings agree with your
derive(Table)Rust structs. It does NOT validate against a production database. If a struct and prod's actual schema have drifted, that drift remains a runtime error. v1 catches developer-side drift only — refactoring one file but not another, typos in column names, queries that select missing columns. That is a real but bounded class of bugs.Who benefits (must be confirmed in Phase 0): Internal hyperdb-api consumers writing typed Rust queries. Note that
hyperdb-mcpgets SQL from LLMs at runtime — it does NOT benefit from compile-time checking. Phase 0 must identify ≥1 real consumer who wants this; otherwise the project should be killed.Reversibility: the new feature ships behind
compile-timecargo feature, off by default. Deprecation policy: if v1 sees no adoption within 90 days post-ship, markquery_as!#[deprecated]and remove in the following minor release.Codebase context (verified)
Workspace root:
/Users/ssteiner/dev/hyper-api-rust. Hyper engine source at../hyper-dbrelative to project root.Cargo.tomlhyperdb-api-core,hyperdb-api,hyperdb-api-derive,hyperdb-api-node,hyperdb-api-salesforce,hyperdb-mcp,hyperdb-bootstrap,sea-query-hyperdb. Edition 2021, Rust 1.81+, version 0.3.1.hyperdb-api-derive/Cargo.tomlproc-macro = true, deps:syn = { version = "2", features = ["full"] },quote = "1",proc-macro2 = "1"FromRowderivehyperdb-api-derive/src/lib.rs:68-210fn from_row(RowAccessor<'_>) -> Result<Self>. Supports#[hyperdb(rename=...)],#[hyperdb(index=N)]. DetectsOption<T>to callget_opt/position_opt. Maps by name (default) or position. ReturnsResult, never panics.FromRowruntime traithyperdb-api/src/result.rs:797-807fn from_row(row: RowAccessor<'_>) -> Result<Self>RowAccessorhyperdb-api/src/row_accessor.rs:54-156&Row+&HashMap<&str, usize>(name → index lookup, built once per query).get<T: RowValue>(name),get_opt,position,position_opt. Errors with `Error::Column { kind: MissingHyperProcesshyperdb-api/src/process.rs:136,246,1126HyperProcess::new(hyper_path, parameters) -> Result<Self>(line 246). Drop callsdo_shutdown(Some(Duration::from_secs(5)))(line 1126). Uses callback-mechanism — TCP listener on ephemeral port; hyperd connects back.Connectionhyperdb-api/src/connection.rs:148,417,468,742,775Connection::new(&proc, db_path, CreateMode). Methods:execute_command,execute_queryreturningRowset,fetch_one_as<T: FromRow>,fetch_all_as<T: FromRow>.Catalog(schema introspection)hyperdb-api/src/catalog.rs:58,221,370,606Catalog::new(&conn),get_table_names,get_table_definition(name) -> TableDefinition(columns +SqlType),get_row_count. Hyper has noDESCRIBESQL form — schema access is purely programmatic.EXPLAINexecute_queryis_read_only_sql("EXPLAIN SELECT 1") == true(hyperdb-api/tests/read_only_tests.rs:17). No structured query-plan API.Rowset/ResultSchema/ResultColumnhyperdb-api/src/result.rs:815,858,956ResultColumn { name, sql_type: SqlType }. Schema accessible after running a query.SqlTypeenumhyperdb-api-core/src/types/sql_type.rs:50Bool, BigInt, SmallInt, Int, Numeric{precision, scale}, Double, Float, Text, Varchar, Char, Date, Timestamp, TimestampTz, Json, …Current runtime behavior under schema drift (verified)
The existing
#[derive(FromRow)]codegen emits onerow.get("col_name")?call per struct field — it never iterates result columns. Behavior matrix:SELECT *SELECT a, bError::Column { kind: Missing }— cleanResult::Err, no panicError::Column { kind: TypeMismatch { expected, actual } }—Result::Err, no panicImplications for the validator: the runtime path is already lenient on additions, strict on drift, with
Result::Err(not panic) for drift. The compile-time validator's job is to shift the strict cases left while preserving the lenient additions semantics. The earlier transcript's "currently it will panic" claim was out of date.Architectural decisions (final)
Three crates, not two (resolves CRITICAL: circular dependency).
hyperdb-apialready depends onhyperdb-api-derive. Addinghyperdb-apias a (feature-gated) dep ofhyperdb-api-derivewould create a cycle Cargo's resolver rejects. Resolution: introduce a new third cratehyperdb-compile-checkthat:hyperdb-apiandhyperdb-api-core(runtime types,HyperProcess,Connection).hyperdb-api-deriveONLY when thecompile-timefeature is enabled.CompileTimeDb, theRegistry, and theLIMIT 0dry-run helper.The macro crate stays dependency-light by default; the third crate carries the heavy runtime deps. No cycle.
One Cargo feature
compile-time, off by default, onhyperdb-api-derive. Without it, only#[derive(FromRow)]exists — zero new dependencies, zero behavior change, zero compile-time hit. Opting in pulls inhyperdb-compile-checkand unlocksderive(Table)+query_as!.Single shared Hyper instance per crate compilation, lazily initialized via
parking_lot::OnceCell<parking_lot::Mutex<CompileTimeDb>>(NOTstd::sync::Mutex— see decision doc: Ssteiner/update docs #5 on poisoning). rustc spawns one proc-macro host process per crate compilation; that process amortizes the ~1–3s startup across every macro invocation in that crate. Drop runs at process exit.Lazy table seeding.
derive(Table) + #[hyperdb(register)]registers a(name, create_sql, fields)entry in the shared Registry but does NOT immediately CREATE TABLE.query_as!walks the SQL withsqlparser, extracts referenced tables, and seeds any registered-but-unmaterialized tables on demand before running the dry-run. This handles cross-file macro expansion ordering.parking_lot::Mutexinstead ofstd::sync::Mutex(resolves CRITICAL: panic poisoning). Proc-macros routinely panic to signal errors.std::sync::Mutexpoisons on panic — one badquery_as!site would cascade-fail every subsequent macro in the crate.parking_lot::Mutexdoes not poison.Validation mechanism:
LIMIT 0dry-run, not EXPLAIN. To get column types we need aResultSchema; EXPLAIN returns plan text. We wrap user SQL asWITH __hdb_q AS (<user-sql>) SELECT * FROM __hdb_q LIMIT 0(using a deliberately-prefixed CTE name to minimize collision risk with user CTEs). The dry-run returns:ResultSchemaof column names+types, ORcompile_error!pinned to the SQL literal's span — whole-literal, not sub-literal; see decision chore(deps)(deps-dev): bump tsx from 4.22.0 to 4.22.1 in /hyperdb-api-node #9).v1 v2-stretch (cost warnings) would add a separate EXPLAIN code path; not in v1 scope.
Honest limitation:
LIMIT 0validates schema correctness (parse + bind + plan), NOT runtime execution. Casts that may fail on real data, division-by-zero, subquery cardinality violations, etc. are not caught. The README must call this out explicitly.Column check is name-based and subset-directional for v1. Confirms every struct field's column-name (honoring
#[hyperdb(rename)], skipping#[hyperdb(index = N)]fields) appears in the result schema. Extras OK (preserves lenient-additions runtime contract). Missing →compile_error!listing all missing names. Type-level cross-check is deferred to v2 — see decision chore: add Dependabot config for cargo, npm, github-actions #8.Type checking is deferred to v2. v1 ships name-only validation. Reasons:
SqlTypetable is structurally unsolvable for newtypes (type UserId = i64), path variations, and<T as Trait>::Output. The proposed#[hyperdb(skip_type_check)]escape hatch would be required on every domain newtype, making the type-checker mostly inactive in real codebases.Error::Column { kind: TypeMismatch }already gives a clean error.RowValueimpl is added).Diagnostics: whole-literal span pinning, not sub-literal (resolves MAJOR: span-pinning over-promise).
proc_macro::Span::source_textand sub-literal byte-range span construction are unstable. On stable rustc,LitStr::span()gives the whole literal's span. v1 squigglies underline the SQL literal as a whole, with the column name embedded in the diagnostic text — not as a sub-span. This is the same UX SQLx ships and is sufficient.derive(Table)always emits CREATE TABLE SQL as apub constand a method on aTabletrait, regardless of#[hyperdb(register)]. The attribute additionally registers the table for compile-time validation. Runtime use ofderive(Table)for migrations/fixtures is independently valuable.query_as!returns a builder.query_as!(T, sql, args...).fetch_all(&conn)?,.fetch_one(&conn)?,.fetch_optional(&conn)?(and async variants). Matches sqlx muscle memory; same prepared query can run on different connections / inside transactions. The runtimeQueryAs<T>type lives inhyperdb-api.Attribute name:
register(notverify, notseed).verifyoverloads a common English word and is ambiguous next to#[hyperdb(primary_key)].registerprecisely describes what it does (registers the table with the compile-time validator).seeddescribes a mechanism not a user-facing intent.Phase 0 — gating spikes (before scheduling any W1 work)
Three short spikes that each could kill or reshape the project. Total: ~3 engineer-days. Do not skip these.
S1. Audience identification (~0.5 day)
Identify ≥1 internal Rust consumer who has explicitly asked for compile-time SQL validation OR who has hit a real bug that this would have caught. Document their use case. Kill criterion: if no real user surfaces, downscope to
derive(Table)only (3-5 days, noquery_as!) or kill the project.S2. rust-analyzer / IDE behavior prototype (~1.5 days)
Build the smallest possible thing that proves the architecture survives an IDE inner loop:
compile-timefeature, stubquery_as!macro that just spins upHyperProcessand shuts it down.Kill criterion: if rust-analyzer triggers Hyper startup on every keystroke and adds >500ms editing latency per keystroke, the in-process-Hyper architecture is wrong. Pivot options:
HYPERDB_URL(closer to sqlx's actual model). Larger effort, less auto-magic.query_as!and ship onlyderive(Table)+ a runtimeverify_schema()helper that runs once at startup.S3. Workspace concurrency stress test (~1 day)
Simulate 16 concurrent
HyperProcess::new()calls in one process (mimicking heavy parallelcargo build -j 16). Verify zero temp-dir collisions, zero leaked dirs after Drop, zero port conflicts on Windows Named Pipes (if possible to test in this environment; otherwise mark as a known-untested risk).Kill criterion: consistent collisions or leaks → architecture needs random-suffix temp dirs (small mitigation, do at this point).
User-facing examples (target v1 ergonomics)
Without
compile-timefeature (existing behavior, unchanged)With
compile-timefeatureCompile-time errors users will see
What still works (lenient additions)
Hard problems & how we handle them
P1. Cross-file macro ordering for table registration
Mitigated by lazy seeding (decision #4).
query_as!walks SQL withsqlparser, extracts table names, and seeds any registered-but-unmaterialized tables before theLIMIT 0dry-run.P2.
query_as!(T, …)cannot seeT's field listProc-macros only see their own token stream.
T's field list is populated into the shared Registry byderive(Table) + #[hyperdb(register)].query_as!looksT's ident up. If not found →compile_error!with a "did you forgetderive(Table)?" hint.P3. sqlparser doesn't know all Hyper extensions
Hyper has SQL extensions (
APPROX_COUNT_DISTINCT, geographic types,MODE() WITHIN GROUP, etc.) thatsqlparsermay not recognize. Mitigation: ifsqlparserfails to parse, we skip the table-extraction step and proceed directly to the dry-run. Hyper will produce the actual diagnostic (parse error, table-not-found, etc.) which we forward intocompile_error!. The only thing we lose is the "did you forgetregister?" hint when both sqlparser fails AND the table is unregistered. That's an acceptable degradation; the user still gets a diagnostic from Hyper itself.P4. Hyper startup cost
3s on first macro invocation × N developers × every clean build. Acceptable for v1; warm builds amortize across all macros. If painful in practice, v2 can add a
target/hyperdb-compile-cache/materialized DB file persisted across builds, hash-invalidated on schema changes. Don't build this yet.P5. SELECT * masking column-name typos
Decision #7's lenient subset-check means
SELECT *, ema1l FROM users(typo in a redundant column the user thought they needed) compiles cleanly because the struct's required columns are still a subset of the result. Accepted v1 trade-off — preserves the lenient-additions invariant. Document in the README. v2 may add a#[hyperdb(strict)]mode that rejects extras.P6. Dry-run wrapper edge cases
The
WITH __hdb_q AS (<user-sql>) SELECT * FROM __hdb_q LIMIT 0wrapper assumes the user SQL is a single SELECT-able expression. Edge cases:WITHclauses correctly. Verified by writing a small integration test.INSERT … RETURNING: also a select-shape, works.INSERT/UPDATE/DELETEwithoutRETURNING: the wrapper fails because there's no result schema. v1 only supportsquery_as!(always SELECT-shape); plain DML is out of scope. Document this.sqlparserstage with a clear "only single statements supported" error.P7. Workspace parallel compilation
Multiple crates compile in parallel; each crate's proc-macro host is its own process. PID-based temp-dir naming in
process.rsis collision-free across processes. Phase 0 spike S3 verifies this empirically. Add random-suffix temp dirs as a fallback if S3 surfaces collisions.Comparison to sqlx — architectural divergences
This project takes the sqlx idea ("validate SQL at compile time against a real DB") and adapts it to Hyper. Several decisions diverge from sqlx — some by necessity (Hyper is embedded, sqlx targets remote servers), some by deliberate choice. Recording them here so reviewers and future maintainers can judge whether the divergences are still justified.
Sorted by significance — biggest divergences first
1. Schema source of truth is the struct, not the database
DATABASE_URL. The triangle (SQL ↔ struct ↔ DB) is fully checked.derive(Table)structs at macro expansion time. The triangle collapses to a line: SQL ↔ struct.DATABASE_URL, nocargo sqlx prepare, no.sqlx/cache). Justified for a v1 that prioritizes zero-config; could be revisited in v2 by allowing a.hypersnapshot path as the schema source.2. Database lifecycle: spawned in-process inside the proc-macro host
HyperProcess::new()to start an embedded Hyper instance inside the rustc proc-macro host. One instance per crate compilation, dropped at host exit.DATABASE_URLsetup burden but introduces real risk in IDE integration (rust-analyzer expansion behavior — Phase 0 S2 is gating for this) and workspace concurrency (Phase 0 S3). Justified iff S2/S3 pass.3. No parameter type checking
PREPAREreturns parameter type metadata.query!("… WHERE id = $1", x)checksx's Rust type matches the inferred parameter type.LIMIT 0dry-run, which returns result schema only — no parameter type info. Parameters are forwarded through to runtime opaquely. Wrong-type parameters fail at runtime, not compile time.prepare-style API (it doesn't today).4. No type checking on result columns (v1)
sqlx-postgres,sqlx-mysql,sqlx-sqlite). Custom types use#[sqlx(type_name = "...")]; newtypes use#[derive(sqlx::Type)] #[sqlx(transparent)].#[hyperdb(skip_type_check)]escape hatch would be required on every domain newtype.5. No
_uncheckedmacro familyquery_unchecked!,query_as_unchecked!, etc. — same syntax as the checked versions but skip validation. Explicit "I need dynamic SQL here" escape hatch in the macro itself.Connection::fetch_all_as/execute_querydirectly when they need dynamic SQL. Functionally equivalent.query_unchecked!(...)and immediately understand "this is a deliberate skip." With v1, the same intent looks like "they didn't bother to use the macro." Worth addingquery_as_unchecked!to v1.5 ifquery_as!sees adoption.6. Lenient column-set semantics
SELECT *site, otherwise the macro errors.FromRowruntime behavior.7. Two-parser pipeline (sqlparser + Hyper) vs. one (DB only)
sqlparserfirst (to extract table names for lazy seeding), then sends to Hyper forLIMIT 0validation.APPROX_COUNT_DISTINCT, geographic types,MODE() WITHIN GROUP, etc.) may not parse with sqlparser. The plan handles this via graceful degradation (P3): if sqlparser fails, skip table extraction and let Hyper produce the diagnostic. Cost: lose the "did you forgetregister?" hint when both sqlparser fails AND a table is unregistered.8. No offline cache
.sqlx/JSON cache committed to repo. CI builds without DB access. Mandatory infrastructure.hyperdb-bootstrap(already wired into.github/workflows/ci.yml).9. Cargo feature posture: opt-in, not headline
query!is the headline feature. You can't use sqlx without compile-time validation (offline cache is optional, validation is not).compile-timecargo feature, off by default. The runtime path is the headline; compile-time is layered on top.FromRowuser base. Different ecosystem position than sqlx — sqlx is the validator;hyperdb-apiis a database client with an optional validator.10. Missing macro suite
query!,query_as!,query_scalar!,query_unchecked!,query_file!,query_file_as!,query_file_scalar!(plus_uncheckedvariants of each), runtimeQueryBuilderfor dynamic SQL.query_as!,query_scalar!. Five+ macros short.query_as!proves itself.11. Macro-logic crate split (sqlx-macros vs. sqlx-macros-core)
sqlx-macros-coreholds the validation logic;sqlx-macrosis a thin proc-macro shell. The split exists specifically so macro logic can be unit-tested without proc-macro constraints (proc-macro crates can't be linked into normal test binaries).hyperdb-api-derivedirectly.hyperdb-compile-checkbecomes the unit-testable home for macro logic (token parsing helpers, SQL extraction, registry queries, name-subset diff);hyperdb-api-derivestays a thin proc-macro shell that calls intohyperdb-compile-check. This is a small workstream tweak (W3 partially relocates) but a meaningful testability win. W3 description below has been updated to reflect this.Smaller divergences (worth listing for completeness)
PREPARE(Postgres/MySQL) /PRAGMA(SQLite)WITH … LIMIT 0dry-runquery_as!(...).fetch_*(executor)query_as!(...).fetch_*(&conn)PREPAREmetadata)LIMIT 0result)When to revisit these divergences
_uncheckedfamily): v1.5 ifquery_as!sees adoption.Workstreams & effort estimate
Total v1 (name-only): ~13–18 engineer-days assuming Phase 0 spikes pass cleanly. Tripled
query_as!budget acknowledges proc-macros + new crate boilerplate is rarely as small as it looks. The W1/W3 split shifts ~1 day of effort from W3 into W1 but the total is unchanged — the macro-core split pays for itself in unit-test velocity (validation logic is testable withouttrybuild).If type-checking is added later (v2): +5–8 days.
If parameter type checking is added later (when/if Hyper exposes a
prepare-style API): +3-5 days. See Comparison divergence #3.W1 — New
hyperdb-compile-checkcrate (4-5 days, +1 day for macro-core split)This crate is the unit-testable home for ALL validation logic, mirroring sqlx's
sqlx-macros-coresplit (see Comparison divergence #11).hyperdb-api-derivestays a thin proc-macro shell; everything else lives here so it can be linked into normal test binaries.Files (new):
hyperdb-compile-check/Cargo.toml— NOT a proc-macro crate (regular library).hyperdb-compile-check/src/lib.rs— public re-exports.hyperdb-compile-check/src/db.rs—CompileTimeDb(ownsHyperProcess+Connection).hyperdb-compile-check/src/registry.rs—Registry(table defs, registered-but-unseeded set, struct field-lists), behindparking_lot::Mutex.hyperdb-compile-check/src/dry_run.rs—LIMIT 0wrapper +ResultSchemaextraction.hyperdb-compile-check/src/sql_parse.rs— sqlparser-driven table-name extraction with graceful failure.hyperdb-compile-check/src/validate.rs— the validation entry point: takes(struct_ident, struct_fields, sql_str)and returnsResult<ValidationOk, ValidationError>. This is whatquery_as_macro.rscalls into fromhyperdb-api-derive. Noproc-macro2/syn/quotetypes in this signature — all token parsing happens in the proc-macro crate; this crate operates on plain Rust types.hyperdb-compile-check/src/diagnostic.rs—ValidationErrorvariants and human-readable formatting. Returns plain strings (or a structured enum); the proc-macro crate converts tocompile_error!tokens.Files modified:
Cargo.toml— add member.Deliverables:
CompileTimeDb::get_or_init() -> &'static parking_lot::Mutex<CompileTimeDb>.Registry::register_schema(ident_str, fields)/register_table(name, create_sql)/seed_if_pending(name).dry_run(sql) -> Result<ResultSchema, HyperError>.extract_referenced_tables(sql) -> Result<Vec<String>, ParseError>(best-effort; returns empty on parse failure with a structured warning).validate_query_as(target_struct: &str, sql: &str) -> Result<ProjectedColumns, ValidationError>— composes the above.LIMIT 0wrapping of representative SQL shapes (CTEs, INSERT…RETURNING, JOINs), name-subset diff (struct ⊆ result OK; struct ⊄ result reports missing columns).get_or_init()from two invocations and confirms one Hyper instance is reused.Why this split matters: if validation logic lived in
hyperdb-api-derive, the only way to test it would betrybuildgolden files — slow, brittle to error-message wording changes, and unable to test internal helpers in isolation. With the split,hyperdb-compile-checkhas standardcargo testcoverage;hyperdb-api-derivegets a small set oftrybuildUI tests just to verify the proc-macro plumbing.W2 —
#[derive(Table)]macro (3-4 days)Files (new):
hyperdb-api-derive/src/table_derive.rsFiles modified:
hyperdb-api-derive/Cargo.toml— add[features] compile-time = ["dep:hyperdb-compile-check"]. New optional dephyperdb-compile-check. (NOThyperdb-api— see decision Welcome to hyper-api-rust Discussions! #1.)hyperdb-api-derive/src/lib.rs—#[proc_macro_derive(Table, attributes(hyperdb))]hyperdb-api/src/lib.rs— runtime traitTable { const NAME: &str; const CREATE_SQL: &str; fn create_sql() -> &'static str; }Attributes supported:
#[hyperdb(table = "name")]— override table name (default = snake_case of ident).#[hyperdb(register)]— register for compile-time validation.#[hyperdb(primary_key)]on a field.#[hyperdb(rename = "...")]on a field — reuse FromRow's parser.Always emitted:
impl Table for MyStructwithCREATE_SQL.With
compile-timefeature +#[hyperdb(register)]: also callsRegistry::register_table(...)andregister_schema(...)at macro expansion time.Type mapping (Rust → SqlType for CREATE TABLE): explicit lookup table covering:
i16 → SmallInt,i32 → Int,i64 → BigInt,f32 → Float,f64 → Double,bool → Bool,String → Text,Vec<u8> → Bytes,chrono::NaiveDate → Date(if chrono feature on),chrono::NaiveDateTime → Timestamp,chrono::DateTime<Utc> → TimestampTz.Option<T>→ nullable. Anything else →compile_error!("unsupported field type for derive(Table); use a custom impl Table").W3 —
query_as!macro (4-6 days, lighter post-split)The proc-macro is now a thin shell over
hyperdb-compile-check::validate_query_as. Heavy lifting (registry, dry-run, name-subset diff) lives in W1's crate where it has unit-test coverage.Files (new):
hyperdb-api-derive/src/query_as_macro.rs— token parsing only. Callshyperdb_compile_check::validate_query_as(target_struct, sql)and translates the result into eithercompile_error!tokens or the success codegen.hyperdb-api/src/query_as.rs— runtimeQueryAs<T>builder type.Files modified:
hyperdb-api-derive/src/lib.rs—#[proc_macro] pub fn query_as(input: TokenStream) -> TokenStream.hyperdb-api/src/lib.rs— re-exportquery_as!(feature-gated) andQueryAs<T>.Macro flow:
T, "SQL" [, arg1, arg2, …]withsyn.compile-timefeature: callhyperdb_compile_check::validate_query_as(&T_ident_string, &sql_str).Err(ValidationError): render tocompile_error!token, span-pinned to the SQL literal's whole-span.Ok: proceed to codegen.Runtime
QueryAs<T>: stores(sql, params). Methodsfetch_all(&conn) -> Result<Vec<T>>,fetch_one(&conn) -> Result<T>,fetch_optional(&conn) -> Result<Option<T>>, plus async variants. Forwards to existingConnection::fetch_*_as<T>.Test surface:
hyperdb-api-derive's own test module — proc-macro2 supports this for parser helpers).trybuildUI tests for end-to-end error rendering — fewer than the original plan, because most logic is unit-tested inhyperdb-compile-check.W4 —
query_scalar!macro (1-2 days, NEW)Added in response to reviewer feedback that v1 without scalar support has a usefulness cliff. Single-column queries:
Validates that the
LIMIT 0dry-run returns exactly one column. Noderive(Table)requirement for the type — justRowValue.W5 — Diagnostics polish (1-2 days)
compile_error!formatting; whole-SQL-literal span pinning.#[derive(Table)] #[hyperdb(register)]?" hint on registry-miss.tests/ui/set oftrybuildsnapshot tests for every error path. Pin Hyper version in the test crate to keep .stderr golden files stable.W6 — Examples + docs (1 day)
examples/compile_time_validation.rs.hyperdb-api-deriveandhyperdb-compile-check.LIMIT 0doesn't catch runtime-evaluation errors (casts, overflows, divisions).rust-analyzer-heavy edit sessions if Phase 0 S2 surfaces problems.FromRowusers do nothing.Out of scope for v1 (explicit)
FromRowstill catches type mismatch.query!(no struct, no scalar) — onlyquery_as!andquery_scalar!.query_as!(User, table = users)). Pure syntactic sugar; the compile-time checker already catches "forgot to project a field"; opening this door inviteswhere=,order_by=,join=, etc., which competes with SQL itself..sqlx-style offline cache. Hyper boots locally; no need.query_builder!macro if demand surfaces.derive(Schema)for read-only structs. Earlier draft mentioned this; cut from v1 to keep surface focused.derive(Table)covers all v1 needs.INSERT/UPDATE/DELETE) withoutRETURNINGinquery_as!. UseConnection::execute_commanddirectly.Verification (v1 acceptance criteria)
derive(Table)produces expected CREATE SQL for representative structs (primitives,Option<T>, renamed columns, primary keys). Snapshot-tested.trybuildUI: every compile-error path has a.stderrgolden file:derive(Table)on type used inquery_as!#[hyperdb(register)]on a registered-style structderive(Table)derive(Table) + #[hyperdb(register)]+query_as!+query_scalar!end-to-end. Builds and runs cleanly.cargo buildfails with a SQL-literal diagnostic that names the missing column.query_as!(T, "SELECT * FROM t")still compiles. Matches runtime behavior.hyperdb-api-deriveandhyperdb-apiwith default features; confirmcargo treeshows zero new transitive deps vs. baseline. Confirmhyperdb-compile-checkis not built.query_as!invocations vs. N=0. Target: < 5s overhead amortized after Phase 0 S2 passes.cargo clippy --workspace --all-targetsandcargo test --workspacecontinue to pass with feature off.cargo build -p hyperdb-api-derive --no-default-featuressucceeds — proves no inadvertent cycle.Success metrics
The feature is judged successful at 90 days post-ship if at least two of the following are true:
query_as!for production queries.query_as!has caught ≥1 real bug in code review or CI before merge.derive(Table)is being used at runtime (migrations, fixtures) by ≥1 internal team.If 0/4 at 90 days, mark
query_as!#[deprecated]and remove in the next minor release.derive(Table)may be retained on its own merits if it has runtime users.Phased delivery
derive(Table)works at runtime;compile-timefeature exists but is empty. Ship as a minor version bump.query_as!+query_scalar!with name-based validation, behind feature flag, marked experimental.#[hyperdb(sql_type = "...")]annotations to sidestep type-name fragility.query_builder!for conditional WHERE.#[hyperdb(strict)]mode for projecting-extra-columns warnings.Pickup checklist for a new chat
If you're picking this up cold, do these in order:
git log --oneline -20to see if anything has shifted since this plan was written.query_as!that just spins upHyperProcess. Use it in rust-analyzer for an hour. Measure latency. Document findings.parking_lotfor sync primitives. Use the three-crate split (decision Welcome to hyper-api-rust Discussions! #1). Do NOT addhyperdb-apias a dep ofhyperdb-api-derivedirectly.cargo clippy && cargo fmt && cargo test --workspace. Commit withgit add <files>(never-A). For PR creation, hand back the body file path —gh pr createis EMU-blocked against upstream.release-please: if afeat:/fix:merge doesn't produce a release PR, check the prior release PR for anautorelease: pendinglabel first.Beta Was this translation helpful? Give feedback.
All reactions