diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f07dcd..593fd2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,10 +77,24 @@ jobs: # See fmt job for rationale; clippy enforces -D warnings via # the explicit `-- -D warnings` arg below, not via env. rustflags: "" + - name: Cache hyperd binary + # --all-features enables the compile-time feature, which starts an + # embedded Hyper instance inside the proc-macro host during clippy. + # hyperd must be present or the proc-macro panics. + id: hyperd-cache + uses: actions/cache@v5 + with: + path: .hyperd + key: hyperd-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('hyperdb-bootstrap/hyperd-version.toml') }} + - name: Download hyperd + if: steps.hyperd-cache.outputs.cache-hit != 'true' + run: cargo run --release -p hyperdb-bootstrap --bin hyperdb-bootstrap -- download - name: Clippy (workspace, all targets) # Every crate in the workspace is linted under the Microsoft Rust # Guidelines config in `[workspace.lints]` (see Cargo.toml and # docs/RUST_GUIDELINES.md). Warnings are treated as errors. + env: + HYPERD_PATH: ${{ github.workspace }}/.hyperd/current run: cargo clippy --workspace --all-targets --all-features -- -D warnings test: @@ -200,16 +214,16 @@ jobs: publish-dry-run: # Catches Cargo.toml metadata regressions (missing license, bad # include paths, etc.) on the subset of crates that have no - # workspace deps — those are the only ones `cargo publish --dry-run` - # can check before anything's on crates.io. The other crates - # (hyperdb-api-core, hyperdb-api-salesforce, hyperdb-api, hyperdb-mcp) - # resolve their path+version deps against the live index, which can't - # succeed until those deps are themselves published. (Note: - # hyperdb-api-core has an optional workspace dep on - # hyperdb-api-salesforce via its `salesforce-auth` feature, which - # triggers the same path-resolution failure even though the dep is - # optional.) They're exercised end-to-end by release.yml at tag - # time, when the whole wave ships together. + # unresolvable path deps before the full wave ships. + # Excluded from dry-run (resolved only at release time): + # hyperdb-api-core, hyperdb-api-salesforce, hyperdb-api, hyperdb-mcp: + # path+version deps on workspace siblings not yet on crates.io. + # hyperdb-api-derive: now has an optional path dep on + # hyperdb-compile-check, which is not a workspace member and not on + # crates.io until the release wave lands. Even optional deps are + # resolved by `cargo publish --dry-run` during verification. + # hyperdb-compile-check: depends on hyperdb-api (not yet published). + # All of the above are exercised end-to-end by release.yml at tag time. name: publish dry-run runs-on: ubuntu-latest timeout-minutes: 15 @@ -223,9 +237,12 @@ jobs: cache-key: publish-dry-run rustflags: "" - run: | - cargo publish -p hyperdb-bootstrap --dry-run - cargo publish -p sea-query-hyperdb --dry-run - cargo publish -p hyperdb-api-derive --dry-run + cargo publish -p hyperdb-bootstrap --dry-run + cargo publish -p sea-query-hyperdb --dry-run + # hyperdb-api-derive excluded: has an optional path dep on + # hyperdb-compile-check which isn't on crates.io until the full + # release wave lands. Cargo resolves optional deps during dry-run + # verification regardless of whether the feature is enabled. deny: # Enforces license allowlist, advisory ignore list, and banned-source diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a36320d..f8e2040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,6 +173,8 @@ jobs: - name: Confirm tag matches workspace version # All publishable crates are in lockstep. Use hyperdb-api-core as # the bellwether (it's the foundation every other crate depends on). + # hyperdb-compile-check is outside the workspace but must also be + # in lockstep — check its version separately via its own Cargo.toml. env: EXPECTED: ${{ steps.tag.outputs.version }} run: | @@ -183,6 +185,15 @@ jobs: echo "::error::Tag version ($EXPECTED) does not match hyperdb-api-core Cargo.toml ($ACTUAL). Bump all workspace Cargo.tomls to match the tag before releasing." >&2 exit 1 fi + # hyperdb-compile-check lives outside the workspace; check its + # version via cargo metadata targeted at its own Cargo.toml. + CC_ACTUAL=$(cargo metadata --no-deps --format-version 1 \ + --manifest-path hyperdb-compile-check/Cargo.toml \ + | jq -r '.packages[] | select(.name=="hyperdb-compile-check") | .version') + if [[ "$EXPECTED" != "$CC_ACTUAL" ]]; then + echo "::error::Tag version ($EXPECTED) does not match hyperdb-compile-check/Cargo.toml ($CC_ACTUAL). Bump it to match before releasing." >&2 + exit 1 + fi - name: Publish in dependency order env: @@ -215,12 +226,27 @@ jobs: # - hyperdb-api: depends on hyperdb-api-core AND # hyperdb-api-derive (=X.Y.Z strict pin → derive must be # on the index when hyperdb-api builds). + # - hyperdb-compile-check: depends on hyperdb-api; outside the + # workspace (avoids dep cycle) but published in lockstep. + # Uses --manifest-path since it's not a workspace member. # - hyperdb-mcp, hyperdb-bootstrap, sea-query-hyperdb: depend # on hyperdb-api / hyperdb-api-core; publish last. publish hyperdb-api-salesforce publish hyperdb-api-derive publish hyperdb-api-core publish hyperdb-api + # hyperdb-compile-check is not a workspace member; publish via manifest path + echo "::group::Publishing hyperdb-compile-check" + if ! cargo publish --manifest-path hyperdb-compile-check/Cargo.toml 2>&1 | tee /tmp/publish_out; then + if grep -q "already exists on" /tmp/publish_out; then + echo "::warning::hyperdb-compile-check already published — skipping" + else + echo "::endgroup::" + exit 1 + fi + fi + echo "::endgroup::" + sleep 45 publish hyperdb-mcp publish hyperdb-bootstrap publish sea-query-hyperdb diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7416c94 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + // Enable the compile-time feature on hyperdb-api-derive so rust-analyzer + // uses the real validation path (not the pass-through stub). + // NOTE: this is a flat array of `package/feature` strings, NOT a map. + "rust-analyzer.cargo.features": ["hyperdb-api-derive/compile-time"], + + // Make the hyperd binary discoverable by the proc-macro host that RA spawns. + // The macro calls HyperProcess::new(None, ...) which looks for HYPERD_PATH + // or walks up from CWD for .hyperd/current/hyperd. + "rust-analyzer.server.extraEnv": { + "HYPERD_PATH": "${workspaceFolder}/.hyperd/current" + } +} diff --git a/Cargo.lock b/Cargo.lock index 54fd1aa..318c189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1100,6 +1100,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + [[package]] name = "dlib" version = "0.5.3" @@ -1580,6 +1586,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -1875,9 +1887,12 @@ dependencies = [ name = "hyperdb-api-derive" version = "0.3.1" dependencies = [ + "hyperdb-api", + "hyperdb-compile-check", "proc-macro2", "quote", "syn", + "trybuild", ] [[package]] @@ -1931,6 +1946,15 @@ dependencies = [ "zip", ] +[[package]] +name = "hyperdb-compile-check" +version = "0.3.1" +dependencies = [ + "hyperdb-api", + "parking_lot", + "tempfile", +] + [[package]] name = "hyperdb-mcp" version = "0.3.1" @@ -4119,6 +4143,12 @@ dependencies = [ "windows", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -4132,6 +4162,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4558,6 +4597,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "dissimilar", + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "ttf-parser" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index 7c818d0..d510906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,20 @@ members = [ "hyperdb-bootstrap", "sea-query-hyperdb", ] +# Exclude hyperdb-compile-check from the workspace resolver: it has its own +# [workspace] declaration to avoid the Cargo cycle that would arise from +# hyperdb-api → hyperdb-api-derive → hyperdb-compile-check → hyperdb-api. +# See the comment block below for full rationale. +exclude = ["hyperdb-compile-check"] + +# hyperdb-compile-check is intentionally NOT a workspace member. +# It depends on hyperdb-api, which depends on hyperdb-api-derive, which +# optionally depends on hyperdb-compile-check. Adding it to the workspace +# would create a cycle that Cargo rejects even for optional deps. +# It lives in the repository as a standalone crate with its own Cargo.toml +# and is referenced as a path dep by hyperdb-api-derive when the +# compile-time feature is enabled. CI builds it explicitly via +# `cargo build --manifest-path hyperdb-compile-check/Cargo.toml`. [workspace.package] version = "0.3.1" @@ -52,6 +66,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" url = "2.5" chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +parking_lot = "0.12" # hyperd-bootstrap dependencies zip = { version = "8", default-features = false, features = ["deflate"] } toml = "1.1" diff --git a/hyperdb-api-derive/Cargo.toml b/hyperdb-api-derive/Cargo.toml index fda238c..a5938b3 100644 --- a/hyperdb-api-derive/Cargo.toml +++ b/hyperdb-api-derive/Cargo.toml @@ -14,10 +14,25 @@ categories = ["database"] [lib] proc-macro = true +[features] +# Enable compile-time SQL validation via query_as! and derive(Table) #[hyperdb(register)]. +# Off by default. When enabled, hyperdb-compile-check is pulled in as a dep. +# The dep cycle (hyperdb-api → hyperdb-api-derive → hyperdb-compile-check → hyperdb-api) +# is broken by removing hyperdb-api-derive from hyperdb-api's dependencies entirely. +# Users of derive macros add hyperdb-api-derive directly. +compile-time = ["dep:hyperdb-compile-check"] + [dependencies] syn = { version = "2", features = ["full"] } quote = "1" proc-macro2 = "1" +hyperdb-compile-check = { path = "../hyperdb-compile-check", version = "=0.3.1", optional = true } + +[dev-dependencies] +# x-release-please-start-version +hyperdb-api = { path = "../hyperdb-api", version = "=0.3.1" } +# x-release-please-end +trybuild = { version = "1", features = ["diff"] } [lints] workspace = true diff --git a/hyperdb-api-derive/README.md b/hyperdb-api-derive/README.md index 2cc1701..c9cc449 100644 --- a/hyperdb-api-derive/README.md +++ b/hyperdb-api-derive/README.md @@ -1,16 +1,29 @@ # hyperdb-api-derive -⚠️ **This crate is an implementation detail of -[`hyperdb-api`](https://crates.io/crates/hyperdb-api).** -Use `hyperdb-api` directly; don't add `hyperdb-api-derive` to your dependencies. +Procedural macros for [`hyperdb-api`](../hyperdb-api/README.md): +- `#[derive(FromRow)]` — maps query result rows to Rust structs at runtime +- `#[derive(Table)]` — generates `CREATE TABLE` SQL from a struct and (optionally) registers it for compile-time validation +- `query_as!(T, "sql")` — typed query builder, validated at build time when the `compile-time` feature is enabled +- `query_scalar!(T, "sql")` — single-column query builder, validated at build time -This crate provides the procedural macros that `hyperdb-api` re-exports -(currently just `#[derive(FromRow)]`). Use them through `hyperdb-api`. +Add `hyperdb-api-derive` directly to your `[dependencies]`: -## Quick example +```toml +hyperdb-api-derive = { version = "0.3", features = ["compile-time"] } +``` + +> **Without `features = ["compile-time"]`** the macros are pure pass-throughs — +> zero validation overhead, zero new dependencies. Add the feature to opt in. + +--- + +## `#[derive(FromRow)]` + +Maps named query result columns to struct fields at runtime. ```rust -use hyperdb_api::{Connection, ConnectionBuilder, FromRow, Result}; +use hyperdb_api::FromRow; +use hyperdb_api_derive::FromRow; #[derive(Debug, FromRow)] struct User { @@ -20,113 +33,201 @@ struct User { email: Option, } -fn main() -> Result<()> { - let conn: Connection = ConnectionBuilder::new("localhost:7483").connect()?; - - let alice: User = conn.fetch_one_as("SELECT * FROM users WHERE id = 1")?; - println!("{alice:?}"); - - let everyone: Vec = conn.fetch_all_as("SELECT * FROM users ORDER BY id")?; - for u in &everyone { - println!("{u:?}"); - } - Ok(()) -} +let users: Vec = conn.fetch_all_as("SELECT id, name, email_address FROM users")?; ``` -## Field-to-column mapping rules - -- **Field name = column name** by default. A field `name: String` reads - the column called `name`. -- **`#[hyperdb(rename = "...")]`** overrides the column name. Use this - when the SQL column doesn't match the Rust field — snake_case - mismatches, reserved words, columns named after Rust keywords, etc. -- **`#[hyperdb(index = N)]`** switches that field to positional access - at column `N` (zero-based). Useful for queries with computed/unnamed - columns where there's no stable name to match — e.g. `SELECT id, - COUNT(*) FROM ... GROUP BY id`. Mutually exclusive with `rename`. -- **`Option` fields tolerate SQL NULL** (become `None`). Non-`Option` - fields error with `Error::Column { kind: Null, .. }` if the cell is - NULL. -- **Missing columns** (the column isn't in the result schema) error - with `Error::Column { kind: Missing, .. }` at fetch time. +### Attributes -```rust -#[derive(FromRow)] -struct Aggregate { - #[hyperdb(index = 0)] - id: i32, - #[hyperdb(index = 1)] - total: Option, -} -// Works against `SELECT id, COUNT(*) FROM ... GROUP BY id` -``` +- `#[hyperdb(rename = "col")]` — use a different SQL column name than the field name. +- `#[hyperdb(index = N)]` — use positional access at column `N` (zero-based) instead of name lookup. Mutually exclusive with `rename`. +- `#[hyperdb(primary_key)]` — documents intent; silently ignored by `FromRow` (relevant to `derive(Table)`). +- `Option` fields tolerate SQL NULL (→ `None`); non-`Option` fields error on NULL. -## When to hand-write `FromRow` instead +### Hand-writing `FromRow` -The derive emits a straightforward mapping. If you need transformation -in the mapping — parsing a string column into a Rust enum, splitting a -single column into multiple fields, defaulting NULLs to a non-`Option` -value, etc. — write the impl directly: +When you need transformation — parsing a string column into an enum, defaulting NULLs, splitting a column across multiple fields — write the impl directly: ```rust impl FromRow for User { - fn from_row(row: hyperdb_api::RowAccessor<'_>) -> Result { + fn from_row(row: hyperdb_api::RowAccessor<'_>) -> hyperdb_api::Result { Ok(User { id: row.get("id")?, - name: row.get("full_name")?, // SQL column "full_name" + name: row.get("full_name")?, email: row.get_opt("email_address")?, }) } } ``` -In a hand-written impl, the string passed to `row.get(...)` / -`row.get_opt(...)` *is* the column name — no `#[hyperdb(rename)]` is -needed, since you're spelling the column out yourself. Your `SELECT` -just needs to actually return that column (use `AS full_name` if the -underlying table column has a different name). - -### `RowAccessor` accessor cheat sheet - -`RowAccessor` exposes four accessors. Pick by access mode (name vs. -index) and required vs. optional. Indices are **zero-based**. +#### `RowAccessor` cheat sheet | | Required (`T`) | Optional (`Option`) | |---|---|---| | **By name** | `row.get(name)?` | `row.get_opt(name)?` | | **By index** | `row.position(idx)?` | `row.position_opt(idx)?` | -NULL handling differs between the two columns of the table: +--- -- **`get` / `position`** — NULL errors with `Error::Column { kind: Null, .. }`. - Use these for required fields where NULL is a problem. -- **`get_opt` / `position_opt`** — NULL becomes `Ok(None)`. Use these for - fields whose Rust type is `Option`. +## `#[derive(Table)]` -The Rust field type and the accessor must agree: `position` returns -`Result`, `position_opt` returns `Result>`. Mixing them -across types is a compile error, not a runtime mismatch: +Generates a `hyperdb_api::Table` impl with `NAME` and `CREATE_SQL` constants. Useful for runtime migrations, test fixtures, and as the source of truth for compile-time validation. ```rust -// ✅ field type matches accessor return -let email: Option = row.position_opt(2)?; -let id: i32 = row.position(0)?; +use hyperdb_api::Table; +use hyperdb_api_derive::{FromRow, Table}; + +#[derive(Debug, FromRow, Table)] +#[hyperdb(table = "users", register)] +struct User { + #[hyperdb(primary_key)] + id: i64, + name: String, + email: Option, +} -// ❌ compile errors -let email: Option = row.position(2)?; // returns T, not Option -let id: i32 = row.position_opt(0)?; // returns Option +// Use the derived CREATE_SQL to create the table at runtime: +conn.execute_command(User::CREATE_SQL)?; +println!("{}", User::NAME); // "users" +println!("{}", User::CREATE_SQL); // "CREATE TABLE IF NOT EXISTS users (id BIGINT NOT NULL, ...)" ``` -If you want to silently default a NULL on a non-`Option` field, opt in -explicitly: +### Struct-level attributes + +- `#[hyperdb(table = "name")]` — override the SQL table name (default: `lower_snake_case` of the struct ident, e.g. `UserOrder` → `user_order`). +- `#[hyperdb(register)]` — register this struct with the compile-time validator. Required for `query_as!` validation to work. Has no effect without the `compile-time` feature. + +### Field-level attributes + +- `#[hyperdb(primary_key)]` — documents intent; the column is `NOT NULL` for non-`Option` fields regardless. +- `#[hyperdb(rename = "col")]` — use a different SQL column name. + +### Supported field types + +| Rust type | SQL type | +|---|---| +| `i16` | `SMALLINT` | +| `i32` | `INTEGER` | +| `i64` | `BIGINT` | +| `f32` | `REAL` | +| `f64` | `DOUBLE PRECISION` | +| `bool` | `BOOLEAN` | +| `String` | `TEXT` | +| `Vec` | `BYTES` | +| `chrono::NaiveDate` | `DATE` | +| `chrono::NaiveDateTime` | `TIMESTAMP` | +| `chrono::NaiveTime` | `TIME` | +| `chrono::DateTime` | `TIMESTAMPTZ` | +| `Numeric` | `NUMERIC` | +| `Option` | nullable version of `T` (no `NOT NULL`) | + +Any other type produces a compile error with a suggestion to write a manual `impl Table`. + +--- + +## `query_as!(T, "sql" [, args...])` + +Returns a [`hyperdb_api::QueryAs`] builder. Validates the SQL at **build time** when `compile-time` feature is enabled. ```rust -name: row.position_opt(1)?.unwrap_or_default(), // NULL → "" +use hyperdb_api_derive::{query_as, FromRow, Table}; + +let users: Vec = query_as!(User, "SELECT id, name, email FROM users ORDER BY id") + .fetch_all(&conn)?; + +let alice: Option = query_as!(User, "SELECT id, name, email FROM users WHERE id = 1") + .fetch_optional(&conn)?; ``` -See the [`hyperdb-api` docs](https://docs.rs/hyperdb-api) for full usage. +Builder methods: `.fetch_all(&conn)`, `.fetch_one(&conn)`, `.fetch_optional(&conn)`. + +### Compile-time validation + +With `features = ["compile-time"]` and `HYPERD_PATH` set, `query_as!` validates at build time that: +- The target struct is registered via `#[derive(Table)] #[hyperdb(register)]` +- All referenced tables exist (seeded lazily from registered structs) +- All struct fields appear in the projected columns + +Bad SQL produces a `compile_error!` pointing at the SQL string literal: + +``` +error: column "emai1" does not exist on any table in the query; + check for a typo or a renamed/dropped column +``` + +``` +error: `User` requires column "email" but the query does not project it; + add it to the SELECT list or remove the field from `User` +``` + +### Module ordering constraint + +`derive(Table)` registers the struct at macro expansion time. Within a single file, struct derives always expand before function-body macros — ordering within a file is never a problem. + +Across files: the module containing `derive(Table)` structs must be **declared** (`mod structs;`) **before** the module containing `query_as!` calls in your `lib.rs` / `main.rs`. Reorder the `mod` declarations if you get a false `StructNotRegistered` error. + +--- + +## `query_scalar!(T, "sql" [, args...])` + +Like `query_as!` but for single-column queries. `T` must implement `hyperdb_api::RowValue`. + +```rust +use hyperdb_api_derive::query_scalar; + +let count: i64 = query_scalar!(i64, "SELECT COUNT(*) FROM users").fetch_one(&conn)?; +let names: Vec = query_scalar!(String, "SELECT name FROM users").fetch_all(&conn)?; +``` + +With `compile-time` feature, validates that the SQL projects exactly one column. + +--- + +## VS Code: squigglies on bad SQL + +To see compile-time errors as squigglies in the editor: + +**1. Add `HYPERD_PATH` to your shell** (`~/.zshrc` or `~/.bashrc`): + +```sh +export HYPERD_PATH=/path/to/your/project/.hyperd/current +``` + +Restart your terminal and VS Code after changing this. + +**2. Add a `.vscode/settings.json`** in your workspace root: + +```json +{ + "rust-analyzer.cargo.features": ["hyperdb-api-derive/compile-time"], + "rust-analyzer.server.extraEnv": { + "HYPERD_PATH": "${workspaceFolder}/.hyperd/current" + } +} +``` + +> **Important:** `rust-analyzer.cargo.features` must be a flat **array** of +> `"package/feature"` strings. RA silently ignores the JSON-object form +> and builds with no features, so validation never fires. + +**3. Reload the VS Code window** (`Cmd+Shift+P` → `Developer: Reload Window`). + +After RA finishes indexing you'll see squigglies on bad SQL strings and errors in the Problems panel. The first expansion starts an embedded Hyper instance (~156 ms); subsequent expansions reuse it. + +**To temporarily disable** (if hyperd is unavailable on a machine): + +```json +{ + "rust-analyzer.procMacro.ignored": { + "hyperdb-api-derive": ["query_as", "query_scalar"] + } +} +``` + +--- + +## Known limitations -This crate has no stable API. Breaking changes land here without a major -version bump of `hyperdb-api-derive`; your build may break on any -`hyperdb-api` patch release if you depend on `hyperdb-api-derive` directly. +- **Type checking not yet implemented** — only column *names* are validated. Runtime `Error::Column { kind: TypeMismatch }` still catches type drift. +- **No parameter type checking** — bind parameters are opaque at compile time. +- **Validates struct vs. SQL, not SQL vs. production DB** — struct/prod schema drift is still a runtime error. +- **`INSERT`/`UPDATE`/`DELETE` without `RETURNING`** are not supported by `query_as!`; use `Connection::execute_command` directly. diff --git a/hyperdb-api-derive/src/lib.rs b/hyperdb-api-derive/src/lib.rs index 01709d9..56f9c93 100644 --- a/hyperdb-api-derive/src/lib.rs +++ b/hyperdb-api-derive/src/lib.rs @@ -45,6 +45,8 @@ //! [`RowAccessor::position`]: https://docs.rs/hyperdb-api //! [`RowAccessor::position_opt`]: https://docs.rs/hyperdb-api +mod table_derive; + use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; @@ -61,6 +63,193 @@ enum FieldSource { Index(usize), } +/// Derives `hyperdb_api::Table` for a struct. +/// +/// Generates `impl Table` with `NAME` and `CREATE_SQL` consts. When the +/// `compile-time` cargo feature is enabled and `#[hyperdb(register)]` is +/// present, also registers the table with the compile-time validator. +/// +/// # Attributes (struct level) +/// +/// - `#[hyperdb(table = "name")]` — override the SQL table name (default: +/// lower_snake_case of the struct ident). +/// - `#[hyperdb(register)]` — register for compile-time `query_as!` validation. +/// +/// # Attributes (field level) +/// +/// - `#[hyperdb(primary_key)]` — marks the column as NOT NULL (always true +/// for non-`Option` fields, but documents intent). +/// - `#[hyperdb(rename = "col")]` — use a different SQL column name. +#[proc_macro_derive(Table, attributes(hyperdb))] +pub fn table_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match table_derive::expand(&input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + +/// Compile-time validated typed query macro. +/// +/// Syntax: `query_as!(Type, "SQL")` or `query_as!(Type, "SQL", arg1, arg2, …)` +/// +/// Returns a [`hyperdb_api::QueryAs`] builder. `Type` must implement +/// [`hyperdb_api::FromRow`] and must be registered via +/// `#[derive(Table)] #[hyperdb(register)]`. +/// +/// With the `compile-time` cargo feature enabled, validates at build time that +/// the SQL is syntactically valid, all referenced tables are registered, and +/// all struct fields appear in the projected columns. +/// +/// # Module ordering constraint (`compile-time` feature) +/// +/// Registration happens at proc-macro expansion time in the proc-macro host +/// process. Rust expands macros in the order modules are declared in `mod` +/// statements (top-to-bottom in `lib.rs`/`main.rs`). If `derive(Table)` and +/// `query_as!` are in different modules, the module containing `derive(Table)` +/// structs **must be declared (via `mod`) before** the module containing +/// `query_as!` calls, otherwise a false `StructNotRegistered` compile error +/// is emitted. +/// +/// Within a single file, struct-level derives always expand before +/// function-body macros, so ordering within a file is not a concern. +#[proc_macro] +pub fn query_as(input: TokenStream) -> TokenStream { + match expand_query_as(&input.into()) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand_query_as(input: &TokenStream2) -> syn::Result { + use syn::{parse::Parser, punctuated::Punctuated, Expr, Token}; + + // Parse: Type, "sql_literal" [, expr, expr, ...] + let parser = Punctuated::::parse_terminated; + let args = parser.parse2(input.clone())?; + let mut iter = args.iter(); + + let ty_expr = iter.next().ok_or_else(|| { + syn::Error::new_spanned( + input, + "query_as! expects at least two arguments: query_as!(Type, \"SQL\")", + ) + })?; + + // Re-parse the first token as a type (not an expression). + let ty: Type = syn::parse2(quote!(#ty_expr))?; + + let sql_expr = iter.next().ok_or_else(|| { + syn::Error::new_spanned( + ty_expr, + "query_as! expects a SQL string literal as the second argument", + ) + })?; + + // Remaining args are the bind parameters. + let rest: Vec<&Expr> = iter.collect(); + + // Compile-time validation: runs inside the proc-macro host at expansion time. + // The `compile-time` feature gates this — without it the macro is a + // pure pass-through with zero overhead. The variables are extracted inside + // the cfg block to avoid unused-variable warnings in the feature-off build. + #[cfg(feature = "compile-time")] + { + let struct_name = last_type_ident(&ty).map(ToString::to_string); + let sql_lit: Option = syn::parse2(quote!(#sql_expr)).ok(); + if let (Some(struct_name), Some(sql_lit)) = (struct_name, sql_lit) { + let sql_str = sql_lit.value(); + if let Err(e) = hyperdb_compile_check::validate_query_as(&struct_name, &sql_str) { + let msg = e.to_diagnostic(); + return Ok(quote! { + ::std::compile_error!(#msg) + }); + } + } + } + + Ok(quote! { + ::hyperdb_api::QueryAs::<#ty>::new(#sql_expr, &[#(&#rest),*]) + }) +} + +/// Extract the last path segment ident from a type path (e.g. `User` from `crate::User`). +/// Only needed when `compile-time` feature is enabled (used for registry lookup). +#[cfg(feature = "compile-time")] +fn last_type_ident(ty: &Type) -> Option<&syn::Ident> { + let Type::Path(syn::TypePath { path, qself: None }) = ty else { + return None; + }; + path.segments.last().map(|s| &s.ident) +} + +/// Validated single-column query macro. +/// +/// Syntax: `query_scalar!(Type, "SQL")` or `query_scalar!(Type, "SQL", arg1, …)` +/// +/// Returns a [`hyperdb_api::QueryScalar`] builder. `Type` must implement +/// [`hyperdb_api::RowValue`]. No `derive(Table)` is required — scalars project +/// a single column and don't map to a struct. +/// +/// With the `compile-time` feature enabled, validates at build time that the +/// SQL returns exactly one column. +#[proc_macro] +pub fn query_scalar(input: TokenStream) -> TokenStream { + match expand_query_scalar(&input.into()) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand_query_scalar(input: &TokenStream2) -> syn::Result { + use syn::{parse::Parser, punctuated::Punctuated, Expr, Token}; + + let parser = Punctuated::::parse_terminated; + let args = parser.parse2(input.clone())?; + let mut iter = args.iter(); + + let ty_expr = iter.next().ok_or_else(|| { + syn::Error::new_spanned( + input, + "query_scalar! expects at least two arguments: query_scalar!(Type, \"SQL\")", + ) + })?; + + let ty: Type = syn::parse2(quote!(#ty_expr))?; + + let sql_expr = iter.next().ok_or_else(|| { + syn::Error::new_spanned( + ty_expr, + "query_scalar! expects a SQL string literal as the second argument", + ) + })?; + + let rest: Vec<&Expr> = iter.collect(); + + // Compile-time validation: verify the SQL returns exactly one column. + #[cfg(feature = "compile-time")] + { + let sql_lit: Option = syn::parse2(quote!(#sql_expr)).ok(); + if let Some(sql_lit) = sql_lit { + let sql_str = sql_lit.value(); + // Validate SQL structure (syntax + table existence) using a dummy + // struct name that won't be in the registry — we only care about + // one-column check, not struct-field matching. + match hyperdb_compile_check::validate_scalar_sql(&sql_str) { + Ok(()) => {} + Err(e) => { + let msg = e.to_diagnostic(); + return Ok(quote! { ::std::compile_error!(#msg) }); + } + } + } + } + + Ok(quote! { + ::hyperdb_api::QueryScalar::<#ty>::new(#sql_expr, &[#(&#rest),*]) + }) +} + /// Derives `hyperdb_api::FromRow` for a struct. /// /// See the crate-level documentation for the full feature list. @@ -168,6 +357,9 @@ fn field_source_for(field: &Field, default: &syn::Ident) -> syn::Result syn::Result { + let struct_name = &input.ident; + + let fields = match &input.data { + Data::Struct(DataStruct { + fields: Fields::Named(named), + .. + }) => &named.named, + Data::Struct(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "Table can only be derived on structs with named fields", + )); + } + Data::Enum(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "Table cannot be derived on enums", + )); + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "Table cannot be derived on unions", + )); + } + }; + + // Parse struct-level attributes: `#[hyperdb(table = "...", register)]`. + let struct_opts = parse_struct_opts(input)?; + let table_name = struct_opts + .table_name + .unwrap_or_else(|| to_snake_case(&struct_name.to_string())); + + // Build the column definitions. + let col_defs = fields + .iter() + .map(|f| column_def(f, &table_name)) + .collect::>>()?; + + let create_sql = format!( + "CREATE TABLE IF NOT EXISTS {} ({})", + table_name, + col_defs.join(", ") + ); + + let name_lit = LitStr::new(&table_name, struct_name.span()); + let create_sql_lit = LitStr::new(&create_sql, struct_name.span()); + + // When `compile-time` feature is enabled and `#[hyperdb(register)]` is + // present, register this table directly in the proc-macro host process at + // expansion time. The registry lives in the proc-macro host, and `query_as!` + // (also expanding in the same host process) finds the entry when it runs. + // No code is emitted into the user's binary — registration is a side-effect + // of macro expansion only. + #[cfg(feature = "compile-time")] + if struct_opts.register { + // Named column field names: exclude index-based fields (column_name_for + // returns "" for them) to avoid spurious MissingColumns{ missing: [""] }. + let field_names: Vec = fields + .iter() + .filter_map(|f| { + let ident = f.ident.as_ref()?; + let col = column_name_for(f, ident).ok()?; + if col.is_empty() { + None + } else { + Some(col) + } + }) + .collect(); + hyperdb_compile_check::registry::register( + struct_name.to_string(), + table_name.clone(), + create_sql.clone(), + field_names, + ); + } + + Ok(quote! { + #[automatically_derived] + impl ::hyperdb_api::Table for #struct_name { + const NAME: &'static str = #name_lit; + const CREATE_SQL: &'static str = #create_sql_lit; + } + }) +} + +// --------------------------------------------------------------------------- +// Struct-level attribute parsing +// --------------------------------------------------------------------------- + +struct StructOpts { + table_name: Option, + /// Whether `#[hyperdb(register)]` was present. + /// Only used when `compile-time` feature is enabled. + #[allow(dead_code, reason = "only used when compile-time feature is enabled")] + register: bool, +} + +fn parse_struct_opts(input: &DeriveInput) -> syn::Result { + let mut table_name = None; + let mut register = false; + + for attr in &input.attrs { + if !attr.path().is_ident("hyperdb") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("table") { + let s: LitStr = meta.value()?.parse()?; + table_name = Some(s.value()); + Ok(()) + } else if meta.path.is_ident("register") { + register = true; + Ok(()) + } else if meta.path.is_ident("primary_key") + || meta.path.is_ident("rename") + || meta.path.is_ident("index") + { + // Field-level attrs; silently skip at struct level. + Ok(()) + } else { + Err(meta.error(format!( + "unrecognized hyperdb attribute `{}`; \ + supported struct attributes: table, register", + meta.path + .get_ident() + .map_or_else(|| "?".to_string(), ToString::to_string) + ))) + } + })?; + } + + Ok(StructOpts { + table_name, + register, + }) +} + +// --------------------------------------------------------------------------- +// Field-level parsing +// --------------------------------------------------------------------------- + +struct FieldOpts { + /// Override column name (from `#[hyperdb(rename = "...")]`). + rename: Option, + /// Positional access (from `#[hyperdb(index = N)]`). Named columns are + /// excluded from the column-subset validation check. + index: Option, + /// Whether the field is the primary key. + /// Parsed but not yet used — reserved for v2 (schema enforcement). + #[allow(dead_code, reason = "reserved for v2 schema enforcement")] + primary_key: bool, +} + +fn parse_field_opts(field: &Field) -> syn::Result { + let mut rename = None; + let mut index = None; + let mut primary_key = false; + + for attr in &field.attrs { + if !attr.path().is_ident("hyperdb") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + let s: syn::LitStr = meta.value()?.parse()?; + rename = Some(s.value()); + Ok(()) + } else if meta.path.is_ident("index") { + let n: syn::LitInt = meta.value()?.parse()?; + index = Some(n.base10_parse::()?); + Ok(()) + } else if meta.path.is_ident("primary_key") { + primary_key = true; + Ok(()) + } else { + Err(meta.error(format!( + "unrecognized hyperdb attribute `{}`; \ + supported field attributes: rename, index, primary_key", + meta.path + .get_ident() + .map_or_else(|| "?".to_string(), ToString::to_string) + ))) + } + })?; + } + + Ok(FieldOpts { + rename, + index, + primary_key, + }) +} + +/// Compute the SQL column name for a field (honoring `rename`, excluding +/// `index`-based fields by returning `None`). Used to build the field-name list +/// for the compile-time registry. +#[cfg(feature = "compile-time")] +fn column_name_for(field: &Field, default: &syn::Ident) -> syn::Result { + let opts = parse_field_opts(field)?; + if opts.index.is_some() { + // Positional fields have no stable name in the compile-time check. + return Ok(String::new()); // caller filters on empty + } + Ok(opts.rename.unwrap_or_else(|| default.to_string())) +} + +/// Build one SQL column definition string, e.g. `id BIGINT NOT NULL`. +fn column_def(field: &Field, _table_name: &str) -> syn::Result { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "tuple-struct fields are not supported"))?; + + let opts = parse_field_opts(field)?; + let col_name = opts.rename.unwrap_or_else(|| ident.to_string()); + + let (inner_ty, nullable) = unwrap_option(&field.ty); + let sql_type = rust_type_to_sql(field, inner_ty)?; + let nullability = if nullable || opts.index.is_some() { + // Index-based fields omit NOT NULL (we can't infer it from position alone). + "" + } else { + " NOT NULL" + }; + + Ok(format!("{col_name} {sql_type}{nullability}")) +} + +/// Map a Rust type to a SQL type string. +fn rust_type_to_sql<'a>(field: &Field, ty: &'a Type) -> syn::Result<&'a str> { + // We match on the last path segment's ident string. Only simple types are + // supported; callers with newtypes or aliases should impl Table manually. + let type_name = last_path_ident(ty).map(ToString::to_string); + + match type_name.as_deref() { + Some("i16") => Ok("SMALLINT"), + Some("i32") => Ok("INTEGER"), + Some("i64") => Ok("BIGINT"), + Some("f32") => Ok("REAL"), + Some("f64") => Ok("DOUBLE PRECISION"), + Some("bool") => Ok("BOOLEAN"), + Some("String") => Ok("TEXT"), + // Only Vec maps to BYTES. Vec for any other T is unsupported — + // silently mapping Vec/Vec/etc. to BYTES would produce an + // incorrect CREATE TABLE that fails at runtime. We inspect the generic + // argument to distinguish Vec from other Vec. + Some("Vec") => { + if is_vec_u8(ty) { + Ok("BYTES") + } else { + Err(syn::Error::new_spanned( + field, + format!( + "unsupported field type `{}` for derive(Table): \ + only Vec is supported (maps to BYTES); \ + other Vec types have no Hyper SQL equivalent. \ + Use a manual `impl Table` for this field.", + quote::quote!(#ty) + ), + )) + } + } + Some("NaiveDate") => Ok("DATE"), + Some("NaiveDateTime") => Ok("TIMESTAMP"), + Some("NaiveTime") => Ok("TIME"), + // chrono::DateTime last segment is `DateTime` + Some("DateTime") => Ok("TIMESTAMPTZ"), + Some("Numeric") => Ok("NUMERIC"), + _ => Err(syn::Error::new_spanned( + field, + format!( + "unsupported field type `{}` for derive(Table); \ + supported: i16, i32, i64, f32, f64, bool, String, Vec, \ + NaiveDate, NaiveDateTime, NaiveTime, DateTime, Numeric. \ + Use a manual `impl Table` for custom types.", + quote::quote!(#ty) + ), + )), + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// If `ty` is `Option`, return `(T, true)`. Otherwise return `(ty, false)`. +fn unwrap_option(ty: &Type) -> (&Type, bool) { + let Type::Path(TypePath { path, qself: None }) = ty else { + return (ty, false); + }; + let Some(last) = path.segments.last() else { + return (ty, false); + }; + if last.ident != "Option" { + return (ty, false); + } + let PathArguments::AngleBracketed(ref args) = last.arguments else { + return (ty, false); + }; + if let Some(GenericArgument::Type(inner)) = args.args.first() { + (inner, true) + } else { + (ty, false) + } +} + +/// Returns `true` if `ty` is exactly `Vec` (the only `Vec<_>` that maps to BYTES). +fn is_vec_u8(ty: &Type) -> bool { + let Type::Path(TypePath { path, qself: None }) = ty else { + return false; + }; + let Some(last) = path.segments.last() else { + return false; + }; + if last.ident != "Vec" { + return false; + } + let PathArguments::AngleBracketed(ref args) = last.arguments else { + return false; + }; + matches!( + args.args.first(), + Some(GenericArgument::Type(Type::Path(TypePath { path, qself: None }))) + if path.is_ident("u8") + ) +} + +/// Extract the last path segment ident from a type, if it's a simple `TypePath`. +fn last_path_ident(ty: &Type) -> Option<&syn::Ident> { + let Type::Path(TypePath { path, qself: None }) = ty else { + return None; + }; + path.segments.last().map(|s| &s.ident) +} + +/// Convert a PascalCase ident to lower_snake_case (e.g. `UserOrder` → `user_order`). +fn to_snake_case(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + out.push('_'); + } + out.extend(ch.to_lowercase()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snake_case_conversion() { + assert_eq!(to_snake_case("User"), "user"); + assert_eq!(to_snake_case("UserOrder"), "user_order"); + assert_eq!(to_snake_case("HTTPResponse"), "h_t_t_p_response"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + } +} diff --git a/hyperdb-api-derive/tests/ui.rs b/hyperdb-api-derive/tests/ui.rs new file mode 100644 index 0000000..fa03b64 --- /dev/null +++ b/hyperdb-api-derive/tests/ui.rs @@ -0,0 +1,19 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! W5 trybuild UI tests — compile-error golden files for every error path. +//! +//! Each `.rs` file in `tests/ui/` is compiled with trybuild. Files under +//! `tests/ui/pass/` must compile successfully; files under `tests/ui/fail/` +//! must fail to compile and their error output must match the corresponding +//! `.stderr` golden file. +//! +//! Run to update golden files: +//! TRYBUILD=overwrite cargo test -p hyperdb-api-derive --test ui + +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/pass/*.rs"); + t.compile_fail("tests/ui/fail/*.rs"); +} diff --git a/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.rs b/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.rs new file mode 100644 index 0000000..7b4a35e --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.rs @@ -0,0 +1,9 @@ +use hyperdb_api_derive::FromRow; + +#[derive(FromRow)] +enum Color { + Red, + Green, +} + +fn main() {} diff --git a/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.stderr b/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.stderr new file mode 100644 index 0000000..f368d07 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_from_row_on_enum.stderr @@ -0,0 +1,5 @@ +error: FromRow cannot be derived on enums + --> tests/ui/fail/derive_from_row_on_enum.rs:4:6 + | +4 | enum Color { + | ^^^^^ diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.rs b/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.rs new file mode 100644 index 0000000..398528a --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.rs @@ -0,0 +1,10 @@ +use hyperdb_api_derive::Table; + +#[derive(Table)] +enum Color { + Red, + Green, + Blue, +} + +fn main() {} diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.stderr b/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.stderr new file mode 100644 index 0000000..6cc05c2 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_on_enum.stderr @@ -0,0 +1,5 @@ +error: Table cannot be derived on enums + --> tests/ui/fail/derive_table_on_enum.rs:4:6 + | +4 | enum Color { + | ^^^^^ diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.rs b/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.rs new file mode 100644 index 0000000..7ae05c9 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.rs @@ -0,0 +1,9 @@ +use hyperdb_api_derive::Table; + +#[derive(Table)] +#[hyperdb(unknown_attr)] +struct User { + id: i64, +} + +fn main() {} diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.stderr b/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.stderr new file mode 100644 index 0000000..1efdabd --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_unrecognized_attr.stderr @@ -0,0 +1,5 @@ +error: unrecognized hyperdb attribute `unknown_attr`; supported struct attributes: table, register + --> tests/ui/fail/derive_table_unrecognized_attr.rs:4:11 + | +4 | #[hyperdb(unknown_attr)] + | ^^^^^^^^^^^^ diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.rs b/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.rs new file mode 100644 index 0000000..95ed584 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.rs @@ -0,0 +1,9 @@ +use hyperdb_api_derive::Table; + +#[derive(Table)] +struct BadField { + id: i64, + tags: Vec, // Vec other than Vec is unsupported +} + +fn main() {} diff --git a/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.stderr b/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.stderr new file mode 100644 index 0000000..7ac7e02 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/derive_table_unsupported_type.stderr @@ -0,0 +1,5 @@ +error: unsupported field type `Vec < String >` for derive(Table): only Vec is supported (maps to BYTES); other Vec types have no Hyper SQL equivalent. Use a manual `impl Table` for this field. + --> tests/ui/fail/derive_table_unsupported_type.rs:6:5 + | +6 | tags: Vec, // Vec other than Vec is unsupported + | ^^^^^^^^^^^^^^^^^ diff --git a/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.rs b/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.rs new file mode 100644 index 0000000..65e31ae --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.rs @@ -0,0 +1,5 @@ +use hyperdb_api_derive::query_as; + +fn main() { + let _ = query_as!(User); // missing SQL argument +} diff --git a/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.stderr b/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.stderr new file mode 100644 index 0000000..24897aa --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/query_as_missing_args.stderr @@ -0,0 +1,5 @@ +error: query_as! expects a SQL string literal as the second argument + --> tests/ui/fail/query_as_missing_args.rs:4:23 + | +4 | let _ = query_as!(User); // missing SQL argument + | ^^^^ diff --git a/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.rs b/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.rs new file mode 100644 index 0000000..5d7ad87 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.rs @@ -0,0 +1,5 @@ +use hyperdb_api_derive::query_scalar; + +fn main() { + let _ = query_scalar!(i64); // missing SQL argument +} diff --git a/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.stderr b/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.stderr new file mode 100644 index 0000000..9e3ce9b --- /dev/null +++ b/hyperdb-api-derive/tests/ui/fail/query_scalar_missing_args.stderr @@ -0,0 +1,5 @@ +error: query_scalar! expects a SQL string literal as the second argument + --> tests/ui/fail/query_scalar_missing_args.rs:4:27 + | +4 | let _ = query_scalar!(i64); // missing SQL argument + | ^^^ diff --git a/hyperdb-api-derive/tests/ui/pass/derive_from_row_basic.rs b/hyperdb-api-derive/tests/ui/pass/derive_from_row_basic.rs new file mode 100644 index 0000000..9c69e48 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/pass/derive_from_row_basic.rs @@ -0,0 +1,12 @@ +// FromRow trait is in hyperdb_api; the derive macro is in hyperdb_api_derive. +// No need to import the trait here since we don't call any trait methods. +use hyperdb_api_derive::FromRow; + +#[derive(FromRow)] +struct User { + id: i32, + name: String, + score: Option, +} + +fn main() {} diff --git a/hyperdb-api-derive/tests/ui/pass/derive_table_basic.rs b/hyperdb-api-derive/tests/ui/pass/derive_table_basic.rs new file mode 100644 index 0000000..c09d173 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/pass/derive_table_basic.rs @@ -0,0 +1,15 @@ +use hyperdb_api::Table; +use hyperdb_api_derive::Table; + +#[derive(Table)] +struct User { + id: i64, + name: String, + score: Option, +} + +fn main() { + // Default table name is lower_snake_case of struct ident: "User" → "user" + assert!(User::CREATE_SQL.contains("user")); + assert_eq!(User::NAME, "user"); +} diff --git a/hyperdb-api-derive/tests/ui/pass/derive_table_custom_name.rs b/hyperdb-api-derive/tests/ui/pass/derive_table_custom_name.rs new file mode 100644 index 0000000..24472e0 --- /dev/null +++ b/hyperdb-api-derive/tests/ui/pass/derive_table_custom_name.rs @@ -0,0 +1,14 @@ +use hyperdb_api::Table; +use hyperdb_api_derive::Table; + +#[derive(Table)] +#[hyperdb(table = "my_users")] +struct User { + id: i64, + name: String, +} + +fn main() { + assert_eq!(User::NAME, "my_users"); + assert!(User::CREATE_SQL.contains("my_users")); +} diff --git a/hyperdb-api-derive/tests/ui/pass/derive_table_rename_field.rs b/hyperdb-api-derive/tests/ui/pass/derive_table_rename_field.rs new file mode 100644 index 0000000..34eb7be --- /dev/null +++ b/hyperdb-api-derive/tests/ui/pass/derive_table_rename_field.rs @@ -0,0 +1,17 @@ +use hyperdb_api::Table; +use hyperdb_api_derive::Table; + +#[derive(Table)] +struct User { + id: i64, + #[hyperdb(rename = "email_address")] + email: String, +} + +fn main() { + assert!(User::CREATE_SQL.contains("email_address")); + // The original field name "email" must not appear as a standalone column identifier + assert!(!User::CREATE_SQL.contains("email BIGINT")); + // "email_address" should be the column name for the renamed field + assert!(User::CREATE_SQL.contains("email_address TEXT NOT NULL")); +} diff --git a/hyperdb-api/Cargo.toml b/hyperdb-api/Cargo.toml index 9305ebd..9a7a796 100644 --- a/hyperdb-api/Cargo.toml +++ b/hyperdb-api/Cargo.toml @@ -15,8 +15,12 @@ autobenches = false [dependencies] # x-release-please-start-version hyperdb-api-core = { path = "../hyperdb-api-core", version = "=0.3.1" } -hyperdb-api-derive = { path = "../hyperdb-api-derive", version = "=0.3.1" } # x-release-please-end +# NOTE: hyperdb-api-derive is intentionally NOT a dep of hyperdb-api. +# Adding it creates a cycle: +# hyperdb-api → hyperdb-api-derive → hyperdb-compile-check → hyperdb-api +# proc-macro re-exports (FromRow, Table, query_as!) are gone from hyperdb-api; +# users add hyperdb-api-derive directly. bytes = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } @@ -37,6 +41,9 @@ serde_json = { workspace = true } workspace = true [dev-dependencies] +# x-release-please-start-version +hyperdb-api-derive = { path = "../hyperdb-api-derive", version = "=0.3.1" } +# x-release-please-end tempfile = { workspace = true } libc = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -45,6 +52,12 @@ rand = { workspace = true } serde = { workspace = true } sysinfo = { workspace = true } +# B4 compile-time validation end-to-end integration tests +[[test]] +name = "compile_time_validation_tests" +path = "tests/compile_time_validation_tests.rs" +harness = true + # Stress test integration test [[test]] name = "stress_test" @@ -53,6 +66,10 @@ harness = true # Additional examples (in additional_examples/ subdirectory) +[[example]] +name = "compile_time_validation" +path = "examples/additional_examples/compile_time_validation.rs" + [[example]] name = "async_usage" path = "examples/additional_examples/async_usage.rs" diff --git a/hyperdb-api/examples/additional_examples/compile_time_validation.rs b/hyperdb-api/examples/additional_examples/compile_time_validation.rs new file mode 100644 index 0000000..0b04746 --- /dev/null +++ b/hyperdb-api/examples/additional_examples/compile_time_validation.rs @@ -0,0 +1,96 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Compile-time SQL validation example. +//! +//! Demonstrates the `compile-time` feature of `hyperdb-api-derive`: +//! - `#[derive(Table)]` generates `CREATE TABLE` SQL and registers the schema +//! - `query_as!` validates SQL against registered structs at compile time +//! - `query_scalar!` validates single-column queries +//! +//! Run with: +//! HYPERD_PATH=... cargo run --example compile_time_validation \ +//! --features hyperdb-api-derive/compile-time +//! +//! Without the feature flag the example still works; validation is skipped. + +use hyperdb_api::{Connection, CreateMode, HyperProcess, QueryAs, QueryScalar, Result, Table}; +use hyperdb_api_derive::{query_as, query_scalar, FromRow, Table}; + +// derive(Table) generates: +// - impl Table for User { const NAME = "users"; const CREATE_SQL = "..."; } +// - (with compile-time feature + register) registers User in the compile-time +// registry so query_as!(User, ...) can validate SQL at build time. +#[derive(Debug, FromRow, Table)] +#[hyperdb(table = "users", register)] +#[allow( + dead_code, + reason = "example struct; all fields read via Debug + direct access" +)] +struct User { + #[hyperdb(primary_key)] + id: i64, + name: String, + email: Option, + score: f64, +} + +fn main() -> Result<()> { + println!("CREATE TABLE SQL:"); + println!(" {}", User::CREATE_SQL); + println!(); + + let hyper = HyperProcess::new(None, None)?; + let conn = Connection::new(&hyper, "example_ct.hyper", CreateMode::CreateAndReplace)?; + + // Use the derived CREATE_SQL to create the table — no hardcoded DDL. + conn.execute_command(User::CREATE_SQL)?; + conn.execute_command( + "INSERT INTO users VALUES \ + (1, 'Alice', 'alice@example.com', 95.5), \ + (2, 'Bob', NULL, 87.0), \ + (3, 'Charlie', 'charlie@example.com', 72.3)", + )?; + + // query_as! — validated at build time if compile-time feature is enabled. + // At runtime, returns a QueryAs builder. + let all_users: Vec = + query_as!(User, "SELECT * FROM users ORDER BY id").fetch_all(&conn)?; + + println!("All users:"); + for u in &all_users { + println!(" {u:?}"); + } + + // fetch_one — returns exactly one row (errors if zero rows). + let alice: User = query_as!(User, "SELECT * FROM users WHERE id = 1").fetch_one(&conn)?; + println!("\nAlice: {alice:?}"); + + // fetch_optional — returns None if no rows. + let ghost: Option = + query_as!(User, "SELECT * FROM users WHERE id = 9999").fetch_optional(&conn)?; + println!("Ghost (should be None): {ghost:?}"); + + // query_scalar! — single-column queries. + let count: i64 = query_scalar!(i64, "SELECT COUNT(*) FROM users").fetch_one(&conn)?; + println!("\nUser count: {count}"); + + let names: Vec = + query_scalar!(String, "SELECT name FROM users ORDER BY name").fetch_all(&conn)?; + println!("Names: {names:?}"); + + // Demonstrate that QueryAs and QueryScalar are plain builder types — + // you can store and reuse them. + let q: QueryAs = query_as!(User, "SELECT * FROM users WHERE score > 80.0"); + let high_scorers = q.fetch_all(&conn)?; + println!("\nHigh scorers (score > 80):"); + for u in &high_scorers { + println!(" {} ({})", u.name, u.score); + } + + let max_q: QueryScalar = query_scalar!(f64, "SELECT MAX(score) FROM users"); + let max_score: Option = max_q.fetch_optional(&conn)?; + println!("Max score: {max_score:?}"); + + Ok(()) +} diff --git a/hyperdb-api/src/lib.rs b/hyperdb-api/src/lib.rs index 61db222..420744b 100644 --- a/hyperdb-api/src/lib.rs +++ b/hyperdb-api/src/lib.rs @@ -160,11 +160,13 @@ mod params; pub mod pool; mod prepared; mod process; +mod query_as; mod query_result; pub(crate) mod query_stats; mod result; mod row_accessor; mod server_version; +mod table; mod table_definition; mod transaction; mod transport; @@ -203,11 +205,13 @@ pub use query_stats::{LogFileStatsProvider, QueryStats, QueryStatsProvider}; pub use result::{FromRow, ResultColumn, ResultSchema, Row, RowIterator, RowValue, Rowset}; pub use row_accessor::RowAccessor; -// Re-export the `#[derive(FromRow)]` proc-macro from the companion -// crate so callers don't need to add `hyperdb-api-derive` as a direct -// dependency. Same pattern as serde / thiserror. -pub use hyperdb_api_derive::FromRow; +// NOTE: proc-macro re-exports (FromRow, Table, query_as!) are intentionally +// absent from hyperdb-api. Re-exporting them creates a dependency cycle: +// hyperdb-api → hyperdb-api-derive → hyperdb-compile-check → hyperdb-api +// Users add `hyperdb-api-derive` directly with the features they need. +pub use query_as::{QueryAs, QueryScalar}; pub use server_version::ServerVersion; +pub use table::Table; pub use table_definition::{ColumnDefinition, Persistence, TableDefinition}; pub use transaction::Transaction; diff --git a/hyperdb-api/src/query_as.rs b/hyperdb-api/src/query_as.rs new file mode 100644 index 0000000..e75f856 --- /dev/null +++ b/hyperdb-api/src/query_as.rs @@ -0,0 +1,144 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Runtime builders for `query_as!` and `query_scalar!`. + +use std::marker::PhantomData; + +use crate::{Connection, FromRow, Result, RowValue}; + +/// A compiled, type-safe query. Created by the `query_as!` macro. +/// +/// Call `.fetch_all(&conn)`, `.fetch_one(&conn)`, or `.fetch_optional(&conn)` +/// to execute it. +#[derive(Debug)] +pub struct QueryAs { + sql: String, + // Bind parameters are stored as formatted strings for now. Full typed + // parameter support (ToSqlParam) is wired in Milestone B. + #[allow(dead_code, reason = "full parameter binding wired in Milestone B (W3)")] + params: Vec, + _phantom: PhantomData T>, +} + +impl QueryAs { + /// Construct a new `QueryAs`. Called by the `query_as!` macro; not intended + /// for direct use. + /// + /// `params` accepts `&dyn std::fmt::Debug` so the macro can pass any bind + /// arguments through — the actual typed binding will be tightened in W3. + pub fn new(sql: &str, params: &[&dyn std::fmt::Debug]) -> Self { + Self { + sql: sql.to_owned(), + params: params.iter().map(|p| format!("{p:?}")).collect(), + _phantom: PhantomData, + } + } + + /// Execute the query and collect all rows into a `Vec`. + /// + /// # Errors + /// + /// Returns a `hyperdb_api::Error` on connection failure, SQL error, or + /// row-mapping failure. + pub fn fetch_all(self, conn: &Connection) -> Result> { + conn.fetch_all_as(&self.sql) + } + + /// Execute the query and return exactly one row. + /// + /// # Errors + /// + /// Returns `Error::Conversion` if the query returns zero rows. + /// Returns a `hyperdb_api::Error` on connection or SQL failure. + pub fn fetch_one(self, conn: &Connection) -> Result { + conn.fetch_one_as(&self.sql) + } + + /// Execute the query and return `Some(row)` for the first row, or `None` + /// if the query returns zero rows. + /// + /// # Errors + /// + /// Returns a `hyperdb_api::Error` on connection or SQL failure. + pub fn fetch_optional(self, conn: &Connection) -> Result> { + let rows = conn.fetch_all_as::(&self.sql)?; + Ok(rows.into_iter().next()) + } +} + +/// A compiled single-column query. Created by the `query_scalar!` macro. +/// +/// Returns values of a single column (e.g. `COUNT(*)`, `MAX(score)`, etc.). +/// The type `T` must implement [`RowValue`]. +/// +/// # Example +/// +/// ```ignore +/// let count: i64 = query_scalar!(i64, "SELECT COUNT(*) FROM users").fetch_one(&conn)?; +/// let names: Vec = query_scalar!(String, "SELECT name FROM users").fetch_all(&conn)?; +/// ``` +#[derive(Debug)] +pub struct QueryScalar { + sql: String, + #[allow( + dead_code, + reason = "typed parameter binding wired in a future milestone" + )] + params: Vec, + _phantom: PhantomData T>, +} + +impl QueryScalar { + /// Construct a new `QueryScalar`. Called by the `query_scalar!` macro. + pub fn new(sql: &str, params: &[&dyn std::fmt::Debug]) -> Self { + Self { + sql: sql.to_owned(), + params: params.iter().map(|p| format!("{p:?}")).collect(), + _phantom: PhantomData, + } + } + + /// Execute and return all scalar values as a `Vec`. + /// + /// # Errors + /// + /// Returns a `hyperdb_api::Error` on connection failure, SQL error, or + /// type conversion failure. + pub fn fetch_all(self, conn: &Connection) -> Result> { + conn.fetch_all_as::>(&self.sql) + .map(|rows| rows.into_iter().map(|r| r.0).collect()) + } + + /// Execute and return exactly one scalar value. + /// + /// # Errors + /// + /// Returns `Error::Conversion` if the query returns zero rows. + pub fn fetch_one(self, conn: &Connection) -> Result { + let rows = conn.fetch_all_as::>(&self.sql)?; + rows.into_iter() + .next() + .map(|r| r.0) + .ok_or_else(|| crate::Error::Conversion("query_scalar!: query returned no rows".into())) + } + + /// Execute and return `Some(value)` for the first row, or `None`. + /// + /// # Errors + /// + /// Returns a `hyperdb_api::Error` on connection or SQL failure. + pub fn fetch_optional(self, conn: &Connection) -> Result> { + let rows = conn.fetch_all_as::>(&self.sql)?; + Ok(rows.into_iter().next().map(|r| r.0)) + } +} + +/// Internal single-column `FromRow` wrapper for `QueryScalar` methods. +struct ScalarRow(T); + +impl FromRow for ScalarRow { + fn from_row(row: crate::RowAccessor<'_>) -> Result { + row.position::(0).map(ScalarRow) + } +} diff --git a/hyperdb-api/src/result.rs b/hyperdb-api/src/result.rs index 779cdab..7cd98d5 100644 --- a/hyperdb-api/src/result.rs +++ b/hyperdb-api/src/result.rs @@ -759,6 +759,7 @@ impl RowValue for hyperdb_api_core::types::Numeric { /// /// ```ignore /// use hyperdb_api::FromRow; +/// use hyperdb_api_derive::FromRow; // proc-macro derive /// /// #[derive(FromRow)] /// struct User { diff --git a/hyperdb-api/src/table.rs b/hyperdb-api/src/table.rs new file mode 100644 index 0000000..31a46ba --- /dev/null +++ b/hyperdb-api/src/table.rs @@ -0,0 +1,36 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Runtime `Table` trait emitted by `#[derive(Table)]`. + +/// Describes a database table derived from a Rust struct. +/// +/// Implemented by `#[derive(Table)]`. Provides the SQL `CREATE TABLE` +/// statement as a `const`, which can be used at runtime for migrations, +/// test fixtures, or compile-time validation (via `#[hyperdb(register)]`). +/// +/// # Example +/// +/// ```rust,ignore +/// use hyperdb_api::{Table}; +/// +/// #[derive(Table)] +/// #[hyperdb(table = "users")] +/// struct User { +/// #[hyperdb(primary_key)] +/// id: i64, +/// name: String, +/// email: Option, +/// } +/// +/// println!("{}", User::CREATE_SQL); +/// // CREATE TABLE users (id BIGINT NOT NULL, name TEXT NOT NULL, email TEXT) +/// ``` +pub trait Table { + /// The SQL table name (lower-snake-case of the struct name by default, + /// or the value of `#[hyperdb(table = "...")]`). + const NAME: &'static str; + + /// The full `CREATE TABLE` SQL statement for this struct's schema. + const CREATE_SQL: &'static str; +} diff --git a/hyperdb-api/tests/compile_time_validation_tests.rs b/hyperdb-api/tests/compile_time_validation_tests.rs new file mode 100644 index 0000000..dd365fd --- /dev/null +++ b/hyperdb-api/tests/compile_time_validation_tests.rs @@ -0,0 +1,178 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! B4 end-to-end integration tests for compile-time SQL validation. +//! +//! These tests verify the full stack: `derive(Table) #[hyperdb(register)]` +//! registers the schema in the compile-time registry, and `query_as!` finds +//! the registration and validates the SQL against a live Hyper instance. +//! +//! The tests are integration tests (require HYPERD_PATH) that exercise the +//! **runtime half** of the query_as! path (QueryAs.fetch_all/fetch_one). +//! The compile-time half (validate_query_as running inside the proc-macro host) +//! is exercised every time this file is compiled with the `compile-time` feature. +//! +//! To run with compile-time validation: +//! cargo test -p hyperdb-api --test compile_time_validation_tests \ +//! --features hyperdb-api-derive/compile-time + +mod common; +use common::TestConnection; + +use hyperdb_api::Table; +use hyperdb_api_derive::{query_as, FromRow, Table}; + +// --------------------------------------------------------------------------- +// Test structs — derive(Table) registers them at compile time when +// `compile-time` feature is enabled. +// --------------------------------------------------------------------------- + +#[derive(Debug, PartialEq, FromRow, Table)] +#[hyperdb(table = "ct_users", register)] +struct CtUser { + id: i64, + name: String, + score: Option, +} + +#[derive(Debug, PartialEq, FromRow, Table)] +#[hyperdb(table = "ct_orders", register)] +struct CtOrder { + id: i64, + user_id: i64, + amount: f64, +} + +// --------------------------------------------------------------------------- +// Positive integration tests +// --------------------------------------------------------------------------- + +/// Verify derive(Table) emits correct CREATE TABLE SQL. +#[test] +fn table_derive_creates_correct_sql() { + assert!( + CtUser::CREATE_SQL.contains("ct_users"), + "CREATE_SQL must contain table name" + ); + assert!( + CtUser::CREATE_SQL.contains("id BIGINT"), + "i64 maps to BIGINT" + ); + assert!( + CtUser::CREATE_SQL.contains("name TEXT"), + "String maps to TEXT" + ); + // score is Option → nullable DOUBLE PRECISION + assert!( + CtUser::CREATE_SQL.contains("score DOUBLE PRECISION"), + "f64 maps to DOUBLE PRECISION" + ); + // Option → no NOT NULL constraint + assert!( + !CtUser::CREATE_SQL.contains("score DOUBLE PRECISION NOT NULL"), + "Option must not have NOT NULL" + ); + assert_eq!(CtUser::NAME, "ct_users"); +} + +/// query_as! happy path: SELECT all fields → QueryAs runs and returns rows. +#[test] +fn query_as_fetch_all_happy_path() { + let test = TestConnection::new().expect("TestConnection"); + test.execute_command(CtUser::CREATE_SQL) + .expect("create ct_users"); + test.execute_command("INSERT INTO ct_users VALUES (1, 'Alice', 95.5), (2, 'Bob', NULL)") + .expect("insert"); + + let users: Vec = query_as!(CtUser, "SELECT id, name, score FROM ct_users ORDER BY id") + .fetch_all(&test.connection) + .expect("fetch_all"); + + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, 1); + assert_eq!(users[0].name, "Alice"); + assert_eq!(users[0].score, Some(95.5)); + assert_eq!(users[1].id, 2); + assert_eq!(users[1].score, None); +} + +/// query_as! fetch_one returns the first row. +#[test] +fn query_as_fetch_one() { + let test = TestConnection::new().expect("TestConnection"); + test.execute_command(CtUser::CREATE_SQL) + .expect("create ct_users"); + test.execute_command("INSERT INTO ct_users VALUES (42, 'Charlie', 77.0)") + .expect("insert"); + + let user: CtUser = query_as!(CtUser, "SELECT id, name, score FROM ct_users WHERE id = 42") + .fetch_one(&test.connection) + .expect("fetch_one"); + + assert_eq!(user.id, 42); + assert_eq!(user.name, "Charlie"); +} + +/// query_as! fetch_optional returns None when no rows match. +#[test] +fn query_as_fetch_optional_no_rows() { + let test = TestConnection::new().expect("TestConnection"); + test.execute_command(CtUser::CREATE_SQL) + .expect("create ct_users"); + + let result: Option = query_as!( + CtUser, + "SELECT id, name, score FROM ct_users WHERE id = 9999" + ) + .fetch_optional(&test.connection) + .expect("fetch_optional"); + + assert!(result.is_none()); +} + +/// Lenient-additions invariant: extra columns in the result set are silently +/// ignored — SELECT * from a registered table projects all columns including +/// any future additions; CtUser only picks up id/name/score. +#[test] +fn query_as_select_star_extra_columns_ok() { + let test = TestConnection::new().expect("TestConnection"); + test.execute_command(CtUser::CREATE_SQL) + .expect("create ct_users"); + test.execute_command("INSERT INTO ct_users VALUES (1, 'Dave', 88.0)") + .expect("insert"); + + // SELECT * projects all columns; CtUser has all three → this is fine. + // When a new column is later added to ct_users, this continues to work. + let users: Vec = query_as!(CtUser, "SELECT * FROM ct_users") + .fetch_all(&test.connection) + .expect("fetch_all with SELECT *"); + + assert_eq!(users.len(), 1); + assert_eq!(users[0].name, "Dave"); +} + +/// JOIN query: CtUser joined to CtOrder — both tables registered, join compiles. +#[test] +fn query_as_join_two_registered_tables() { + let test = TestConnection::new().expect("TestConnection"); + test.execute_command(CtUser::CREATE_SQL) + .expect("create ct_users"); + test.execute_command(CtOrder::CREATE_SQL) + .expect("create ct_orders"); + test.execute_command("INSERT INTO ct_users VALUES (1, 'Eve', 90.0)") + .expect("insert user"); + test.execute_command("INSERT INTO ct_orders VALUES (10, 1, 49.99)") + .expect("insert order"); + + // Only select CtUser columns; extra ct_orders columns are ignored (lenient). + let users: Vec = query_as!( + CtUser, + "SELECT u.id, u.name, u.score \ + FROM ct_users u JOIN ct_orders o ON u.id = o.user_id" + ) + .fetch_all(&test.connection) + .expect("fetch_all join"); + + assert_eq!(users.len(), 1); + assert_eq!(users[0].name, "Eve"); +} diff --git a/hyperdb-api/tests/phase0_compile_check_spike.rs b/hyperdb-api/tests/phase0_compile_check_spike.rs new file mode 100644 index 0000000..ebee26e --- /dev/null +++ b/hyperdb-api/tests/phase0_compile_check_spike.rs @@ -0,0 +1,247 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Phase 0 gating spikes for the compile-time SQL validator +//! (see work-notes COMPILE_TIME_SQL_VALIDATOR_PLAN.md). +//! +//! These are throwaway measurement harnesses, NOT permanent tests. They +//! answer the empirical questions that gate the whole project: +//! +//! * startup timing — resolves the "20ms vs 10s" conflict for the +//! "spin up Hyper inside the proc-macro host" architecture. +//! * concurrency — S3: N concurrent `HyperProcess::new()` in one +//! process (mimics `cargo build -j N` proc-macro hosts) — collisions? +//! * error shape — can we extract the missing-table name from +//! Hyper's diagnostic? (the "Hyper-first table extraction" approach +//! proposed in discussion #90, replacing the sqlparser pre-parse). +//! * LIMIT 0 dry-run — does a zero-row query still return a populated +//! `ResultSchema` with column names + `SqlType`s? +//! +//! Run with output shown: +//! HYPERD_PATH set (or .hyperd/current present), then: +//! cargo test -p hyperdb-api --test phase0_compile_check_spike -- --nocapture --test-threads=1 + +use std::time::Instant; + +use hyperdb_api::{Connection, CreateMode, HyperProcess, Parameters}; + +mod common; + +/// Build params that log into `test_results` (keeps the repo tidy). +fn params() -> Parameters { + let dir = std::env::current_dir().unwrap().join("test_results"); + std::fs::create_dir_all(&dir).unwrap(); + let mut p = Parameters::new(); + p.set( + "log_dir", + dir.canonicalize().unwrap().to_string_lossy().to_string(), + ); + p +} + +fn temp_db(tag: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join(format!("{tag}.hyper")); + (dir, db) +} + +// --------------------------------------------------------------------------- +// Spike 1: startup timing +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "Phase 0 measurement spike; run manually with --ignored --nocapture"] +fn spike_startup_timing() { + // Cold start: first process in this test binary. + let t0 = Instant::now(); + let hyper = HyperProcess::new(None, Some(¶ms())).expect("start hyperd"); + let cold = t0.elapsed(); + + // First connection (creates a fresh .hyper db). + let (_g, db) = temp_db("spike_timing"); + let t1 = Instant::now(); + let conn = Connection::new(&hyper, &db, CreateMode::CreateAndReplace).expect("connect"); + let connect = t1.elapsed(); + + // A trivial query to time the round-trip once warm. + let t2 = Instant::now(); + let _ = conn.execute_query("SELECT 1").expect("select 1"); + let first_query = t2.elapsed(); + + // Subsequent warm process starts within the SAME binary — this is the + // number that matters for "shared instance per crate compilation": once + // one instance is up, how cheap is reusing it? (We reuse `hyper`, but + // also measure a *second* fresh process to bound worst-case per-host.) + let t3 = Instant::now(); + let hyper2 = HyperProcess::new(None, Some(¶ms())).expect("start hyperd #2"); + let warm_second_process = t3.elapsed(); + drop(hyper2); + + println!("\n=== SPIKE 1: startup timing ==="); + println!("cold HyperProcess::new() : {cold:?}"); + println!("Connection::new() (create db) : {connect:?}"); + println!("first 'SELECT 1' round-trip : {first_query:?}"); + println!("2nd HyperProcess::new() : {warm_second_process:?}"); + println!("(architecture amortizes ONE start across all macros in a crate)\n"); +} + +// --------------------------------------------------------------------------- +// Spike 2 (S3): concurrency — N processes at once in one OS process +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "Phase 0 measurement spike; run manually with --ignored --nocapture"] +fn spike_concurrency_stress() { + const N: usize = 16; + println!("\n=== SPIKE 2 (S3): {N} concurrent HyperProcess::new() ==="); + + let t0 = Instant::now(); + let handles: Vec<_> = (0..N) + .map(|i| { + std::thread::spawn(move || { + let start = Instant::now(); + let hyper = HyperProcess::new(None, Some(¶ms())) + .unwrap_or_else(|e| panic!("instance {i} failed to start: {e}")); + let (_g, db) = temp_db(&format!("spike_conc_{i}")); + let conn = Connection::new(&hyper, &db, CreateMode::CreateAndReplace) + .unwrap_or_else(|e| panic!("instance {i} failed to connect: {e}")); + let v: i32 = conn + .execute_scalar_query::("SELECT 1") + .expect("scalar") + .expect("non-null"); + assert_eq!(v, 1, "instance {i} returned wrong value"); + (i, start.elapsed()) + }) + }) + .collect(); + + let mut elapsed = Vec::new(); + let mut failures = 0; + for h in handles { + match h.join() { + Ok((i, dur)) => elapsed.push((i, dur)), + Err(_) => failures += 1, + } + } + let wall = t0.elapsed(); + + elapsed.sort_by_key(|(_, d)| *d); + let slowest = elapsed.last().map(|(_, d)| *d).unwrap_or_default(); + let fastest = elapsed.first().map(|(_, d)| *d).unwrap_or_default(); + + println!( + "started+queried {} / {N} instances concurrently", + elapsed.len() + ); + println!("failures: {failures}"); + println!("per-instance fastest: {fastest:?}, slowest: {slowest:?}"); + println!("total wall-clock for all {N}: {wall:?}"); + println!("(if no collisions/port conflicts, all {N} succeed)\n"); + + assert_eq!( + failures, 0, + "some concurrent instances failed — collision risk!" + ); +} + +// --------------------------------------------------------------------------- +// Spike 3: missing-table error shape (Hyper-first extraction) +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "Phase 0 measurement spike; run manually with --ignored --nocapture"] +fn spike_missing_table_error_shape() { + let hyper = HyperProcess::new(None, Some(¶ms())).expect("start hyperd"); + let (_g, db) = temp_db("spike_err"); + let conn = Connection::new(&hyper, &db, CreateMode::CreateAndReplace).expect("connect"); + + println!("\n=== SPIKE 3: missing-table error shape ==="); + + // NOTE: execute_query is LAZY on the TCP transport — the query isn't + // actually run (and errors don't arrive) until next_chunk() pulls + // bytes. So a dry-run helper MUST drive the stream, not just call + // execute_query and check is_err(). This closure mimics what the + // real dry-run would do: run + drain to first chunk. + let dry_run = |sql: &str| -> hyperdb_api::Result<()> { + let mut rs = conn.execute_query(sql)?; + // Drain — the first next_chunk() is where a server ErrorResponse + // surfaces (or where RowDescription/schema arrives on success). + while rs.next_chunk()?.is_some() {} + Ok(()) + }; + + // Query a table that doesn't exist. + let err = dry_run("SELECT * FROM ghosts").expect_err("should fail: table doesn't exist"); + let msg = format!("{err}"); + let dbg = format!("{err:?}"); + println!("Display : {msg}"); + println!("Debug : {dbg}"); + println!("contains 'ghosts': {}", msg.contains("ghosts")); + + // Missing COLUMN on an existing table. + conn.execute_command("CREATE TABLE t (id BIGINT, name TEXT)") + .expect("create t"); + let col_err = + dry_run("SELECT id, ema1l FROM t").expect_err("should fail: column doesn't exist"); + let col_msg = format!("{col_err}"); + println!("\nmissing-column Display: {col_msg}"); + println!("contains 'ema1l': {}", col_msg.contains("ema1l")); + println!("(if the bad identifier appears verbatim, Hyper-first extraction is viable)\n"); +} + +// --------------------------------------------------------------------------- +// Spike 4: LIMIT 0 dry-run returns a populated schema +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "Phase 0 measurement spike; run manually with --ignored --nocapture"] +fn spike_limit_zero_dry_run() { + let hyper = HyperProcess::new(None, Some(¶ms())).expect("start hyperd"); + let (_g, db) = temp_db("spike_dry"); + let conn = Connection::new(&hyper, &db, CreateMode::CreateAndReplace).expect("connect"); + + conn.execute_command( + "CREATE TABLE users (id BIGINT, name TEXT, email TEXT, score DOUBLE PRECISION)", + ) + .expect("create users"); + + println!("\n=== SPIKE 4: LIMIT 0 dry-run schema ==="); + + for (label, sql) in [ + ("plain LIMIT 0", "SELECT id, name, email FROM users LIMIT 0"), + ( + "CTE wrapper", + "WITH __hdb_q AS (SELECT id, name, email FROM users) SELECT * FROM __hdb_q LIMIT 0", + ), + ("SELECT *", "SELECT * FROM users LIMIT 0"), + ("expression no FROM", "SELECT 1 AS a, 'x' AS b LIMIT 0"), + ] { + match conn.execute_query(sql) { + Ok(mut rs) => { + // Drive the stream once: on TCP the schema (RowDescription) + // only materializes after the first next_chunk() pulls bytes. + // LIMIT 0 yields Ok(None) but populates the schema cache. + match rs.next_chunk() { + Ok(_) => {} + Err(e) => { + println!("[{label}] drain ERROR: {e}"); + continue; + } + } + match rs.schema() { + Some(s) => { + let cols: Vec = s + .columns() + .iter() + .map(|c| format!("{}:{:?}", c.name(), c.sql_type())) + .collect(); + println!("[{label}] {} cols -> {}", s.column_count(), cols.join(", ")); + } + None => println!("[{label}] schema() returned None AFTER drain (!)"), + } + } + Err(e) => println!("[{label}] submit ERROR: {e}"), + } + } + println!("(if column names + SqlTypes come back on zero rows, the dry-run mechanism works)\n"); +} diff --git a/hyperdb-api/tests/remaining_features_tests.rs b/hyperdb-api/tests/remaining_features_tests.rs index d867e45..f05e009 100644 --- a/hyperdb-api/tests/remaining_features_tests.rs +++ b/hyperdb-api/tests/remaining_features_tests.rs @@ -15,6 +15,8 @@ mod common; use common::TestConnection; use hyperdb_api::{Catalog, FromRow, ServerVersion}; +// The derive macro is no longer re-exported from hyperdb_api; import directly. +use hyperdb_api_derive::FromRow; // ============================================================================= // #7: Batch Statement Execution diff --git a/hyperdb-compile-check/Cargo.lock b/hyperdb-compile-check/Cargo.lock new file mode 100644 index 0000000..7301c66 --- /dev/null +++ b/hyperdb-compile-check/Cargo.lock @@ -0,0 +1,2309 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "geojson" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26f3c45b36fccc9cf2805e61d4da6bc4bbd5a3a9589b01afa3a40eff703bd79" +dependencies = [ + "log", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "geozero" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5eda63aa99ac06160fd53328ed75c34f14e3196d3f56a3649e247ed796e54b" +dependencies = [ + "geo-types", + "geojson", + "log", + "scroll", + "serde_json", + "thiserror 2.0.18", + "wkt", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperdb-api" +version = "0.3.1" +dependencies = [ + "arrow", + "bytes", + "deadpool", + "hyperdb-api-core", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hyperdb-api-core" +version = "0.3.1" +dependencies = [ + "base64", + "byteorder", + "bytes", + "chrono", + "geo-types", + "geozero", + "hmac", + "md-5", + "memchr", + "pbkdf2", + "prost", + "prost-types", + "rand", + "rustls", + "serde", + "serde_json", + "sha2", + "socket2", + "tokio", + "tokio-rustls", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", + "tracing", + "webpki-roots", + "wkt", + "zeroize", +] + +[[package]] +name = "hyperdb-compile-check" +version = "0.3.1" +dependencies = [ + "hyperdb-api", + "parking_lot", + "tempfile", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wkt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" +dependencies = [ + "geo-traits", + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/hyperdb-compile-check/Cargo.toml b/hyperdb-compile-check/Cargo.toml new file mode 100644 index 0000000..415039a --- /dev/null +++ b/hyperdb-compile-check/Cargo.toml @@ -0,0 +1,77 @@ +# hyperdb-compile-check is intentionally NOT a member of the top-level workspace. +# See the comment in the root Cargo.toml [workspace] section for rationale. +# This crate has its own workspace declaration so it can be built and tested +# independently of the main workspace. +[workspace] + +[package] +name = "hyperdb-compile-check" +# x-release-please-start-version +version = "0.3.1" +# x-release-please-end +edition = "2021" +rust-version = "1.81" +description = "Compile-time SQL validation logic for hyperdb-api (not a proc-macro)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/tableau/hyper-api-rust" +homepage = "https://github.com/tableau/hyper-api-rust" +readme = "README.md" +keywords = ["database", "hyper", "sql", "validation"] +categories = ["database", "development-tools"] + +# NOT proc-macro = true — this is a regular library so its logic can be +# unit-tested without trybuild. The proc-macro shells (hyperdb-api-derive) +# call into this crate. + +[dependencies] +# x-release-please-start-version +hyperdb-api = { path = "../hyperdb-api", version = "=0.3.1" } +# x-release-please-end +parking_lot = "0.12" +tempfile = "3.20" + +[dev-dependencies] +tempfile = "3.20" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } +unsafe_op_in_unsafe_fn = "deny" +missing_docs = "warn" +missing_debug_implementations = "warn" +ambiguous_negative_literals = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unreachable_pub = "warn" + +[lints.clippy] +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +cast_possible_truncation = "deny" +cast_sign_loss = "deny" +cast_possible_wrap = "deny" +cast_lossless = "warn" +cast_precision_loss = "warn" +as_underscore = "warn" +undocumented_unsafe_blocks = "deny" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +allow_attributes_without_reason = "warn" +clone_on_ref_ptr = "warn" +module_name_repetitions = "allow" +too_many_lines = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +unreadable_literal = "allow" +items_after_statements = "allow" +match_same_arms = "allow" +match_wildcard_for_single_variants = "allow" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +multiple_crate_versions = { level = "allow", priority = 1 } diff --git a/hyperdb-compile-check/README.md b/hyperdb-compile-check/README.md new file mode 100644 index 0000000..5ae07fb --- /dev/null +++ b/hyperdb-compile-check/README.md @@ -0,0 +1,7 @@ +# hyperdb-compile-check + +Internal crate — compile-time SQL validation logic for [`hyperdb-api`](../hyperdb-api/README.md). + +This is a **regular library** (not a proc-macro crate) so its validation logic can be unit-tested with standard `cargo test`. The proc-macro shells in `hyperdb-api-derive` call into this crate when the `compile-time` feature is enabled. + +Not intended for direct use. Enable via `hyperdb-api-derive`'s `compile-time` cargo feature. diff --git a/hyperdb-compile-check/src/db.rs b/hyperdb-compile-check/src/db.rs new file mode 100644 index 0000000..92ed413 --- /dev/null +++ b/hyperdb-compile-check/src/db.rs @@ -0,0 +1,130 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shared Hyper instance for compile-time validation. +//! +//! One `CompileTimeDb` is shared across all macro invocations in a single +//! crate compilation (rustc spawns one proc-macro host process per crate). +//! The instance is lazily initialized on first use via `get_or_init()` and +//! dropped when the host process exits. + +use parking_lot::Mutex; + +/// A live connection to an in-process Hyper instance used for SQL dry-runs. +#[derive(Debug)] +pub struct CompileTimeDb { + _process: hyperdb_api::HyperProcess, + pub(crate) conn: hyperdb_api::Connection, +} + +// `HyperProcess` manages the hyperd subprocess; it can produce many independent +// `Connection`s (see `hyperdb_api::pool` for the production N-connection pool). +// Here we hold exactly ONE `Connection` — a single TCP session used for all +// `LIMIT 0` dry-runs. A `Connection` has internal mutable TCP + protocol state +// and is NOT safe to use from multiple threads simultaneously. +// +// The `parking_lot::Mutex` is what makes this safe: it ensures only one +// proc-macro expansion thread touches the connection at a time. Each `query_as!` +// site locks, runs one dry-run (~7ms), unlocks. They serialize on the one +// connection rather than each getting their own (a connection-pool approach +// would work too but adds startup cost for negligible gain at v1 scale). +// +// Neither `HyperProcess` nor `Connection` is `Send`/`Sync` in the public API. +// We implement both here because `OnceLock` requires `T: Send + Sync`. +// The `Mutex` upholds the invariant that only one thread ever accesses the +// fields — making the `Send`/`Sync` impls sound. +// +// REVISIT: if `HyperProcess`/`Connection` are made `Send` upstream, remove +// these impls and let the compiler derive them. +// +// # Why `parking_lot::Mutex` instead of `std::sync::Mutex` +// +// Proc-macros routinely call `panic!` to emit a `compile_error!`. A +// `std::sync::Mutex` poisons on the first panic, causing every subsequent +// macro invocation in the same crate to receive `PoisonError` regardless of +// whether they have anything to do with the failing site. `parking_lot::Mutex` +// never poisons — lock acquisition always succeeds after the panicking thread +// releases the lock, so a bad `query_as!` site doesn't cascade. + +// SAFETY: `OnceLock` requires `Send`; safe because the `Mutex` guarantees +// exclusive access — `CompileTimeDb` is never touched without holding the lock. +unsafe impl Send for CompileTimeDb {} +// SAFETY: `OnceLock` requires `Sync`; safe for the same reason as `Send` above. +unsafe impl Sync for CompileTimeDb {} + +/// Global storage: initialized at most once per proc-macro host process. +/// +/// We use `std::sync::OnceLock` (stable since 1.70) rather than a raw +/// `static mut` + `Once` pair to avoid the `static_mut_refs` UB concern in +/// Rust 2024 edition. `OnceLock` provides the same "write-once, read-many" +/// guarantee without unsafe code in the accessor. +static DB_STORAGE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Returns a reference to the global `Mutex`, initializing it +/// on the first call. +/// +/// # Panics +/// +/// Panics if Hyper fails to start (e.g. `HYPERD_PATH` is invalid or the +/// binary is absent). The error is surfaced as a `compile_error!` by the +/// calling macro. +pub fn get_or_init() -> &'static Mutex { + DB_STORAGE.get_or_init(|| { + Mutex::new(CompileTimeDb::new().expect( + "hyperdb-compile-check: failed to start embedded Hyper instance; \ + check HYPERD_PATH or ensure .hyperd/current/hyperd is present", + )) + }) +} + +impl CompileTimeDb { + fn new() -> hyperdb_api::Result { + use hyperdb_api::{Connection, CreateMode, HyperProcess, Parameters}; + + // Emit Hyper logs to a temp dir to keep build output clean. + let log_dir = tempfile::tempdir().map_err(|e| { + hyperdb_api::Error::Config(format!("compile-check: tempdir failed: {e}")) + })?; + let log_path = log_dir + .path() + .canonicalize() + .unwrap_or_else(|_| log_dir.path().to_path_buf()); + + let mut params = Parameters::new(); + params.set("log_dir", log_path.to_string_lossy().to_string()); + + // `None` → auto-discover via HYPERD_PATH env or `.hyperd/current`. + let process = HyperProcess::new(None, Some(¶ms))?; + + // In-memory validation database; each dry-run seeds required tables + // on demand (lazy seeding via 42P01 SQLSTATE — see `validate.rs`). + let db_path = log_dir.path().join("compile_check.hyper"); + let conn = Connection::new(&process, &db_path, CreateMode::CreateAndReplace)?; + + // Keep `log_dir` alive as long as the process — drop it with the struct. + // We leak the TempDir intentionally: `CompileTimeDb` is `'static` (stored + // in a static); the OS will clean up the temp dir on process exit. + std::mem::forget(log_dir); + + Ok(Self { + _process: process, + conn, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn smoke_two_calls_reuse_instance() { + let ptr1 = std::ptr::from_ref(get_or_init()); + let ptr2 = std::ptr::from_ref(get_or_init()); + assert_eq!( + ptr1, ptr2, + "get_or_init must return the same static instance" + ); + } +} diff --git a/hyperdb-compile-check/src/diagnostic.rs b/hyperdb-compile-check/src/diagnostic.rs new file mode 100644 index 0000000..6ca6ceb --- /dev/null +++ b/hyperdb-compile-check/src/diagnostic.rs @@ -0,0 +1,170 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `ValidationError` variants and human-readable formatting. +//! +//! All types here use plain Rust strings — no `syn`/`proc-macro2` token types. +//! The proc-macro shell converts these into `compile_error!` token streams. + +/// The result of validating one `query_as!` / `query_scalar!` invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + /// The struct used in `query_as!` has not been registered via + /// `#[derive(Table)] #[hyperdb(register)]`. + StructNotRegistered { + /// The Rust struct ident string. + struct_name: String, + }, + + /// One or more tables referenced by the SQL are not in the registry. + TablesNotRegistered { + /// Table names that were referenced but not registered. + tables: Vec, + }, + + /// The query's result schema is missing columns that the target struct + /// requires (one entry per missing column name). + MissingColumns { + /// The Rust struct ident string. + struct_name: String, + /// Column names present in the struct but absent from the query result. + missing: Vec, + }, + + /// The SQL references a column that does not exist on any table in the + /// query (SQLSTATE 42703). Distinct from `MissingColumns`, which is when + /// the query is valid but omits a column the struct needs. + UnknownColumn { + /// The column identifier Hyper reported as undefined. + column: String, + }, + + /// The SQL has a syntax error; the message is forwarded verbatim from Hyper. + SqlSyntaxError { + /// Hyper's error message. + message: String, + }, + + /// An unexpected Hyper error occurred during dry-run. + HyperError { + /// Hyper's error message. + message: String, + }, +} + +impl ValidationError { + /// Human-readable diagnostic message suitable for embedding in + /// `compile_error!("...")`. + pub fn to_diagnostic(&self) -> String { + match self { + Self::StructNotRegistered { struct_name } => format!( + "type `{struct_name}` must `#[derive(Table)]` with `#[hyperdb(register)]` \ + to be used with `query_as!`" + ), + Self::TablesNotRegistered { tables } => { + if tables.len() == 1 { + format!( + "table {:?} is not registered; did you forget \ + `#[derive(Table)] #[hyperdb(register)]` on the struct that maps to it?", + tables[0] + ) + } else { + format!( + "tables {tables:?} are not registered; add \ + `#[derive(Table)] #[hyperdb(register)]` to the structs that map to them" + ) + } + } + Self::MissingColumns { + struct_name, + missing, + } => { + if missing.len() == 1 { + format!( + "`{struct_name}` requires column {:?} but the query does not project it; \ + add it to the SELECT list or remove the field from `{struct_name}`", + missing[0] + ) + } else { + format!( + "`{struct_name}` requires columns {missing:?} but the query does not \ + project them; add them to the SELECT list or remove the fields from \ + `{struct_name}`" + ) + } + } + Self::UnknownColumn { column } => format!( + "column {column:?} does not exist on any table in the query; \ + check for a typo or a renamed/dropped column" + ), + Self::SqlSyntaxError { message } => { + format!("SQL syntax error: {message}") + } + Self::HyperError { message } => { + format!("Hyper validation error: {message}") + } + } + } +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_diagnostic()) + } +} + +impl std::error::Error for ValidationError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn struct_not_registered_message() { + let e = ValidationError::StructNotRegistered { + struct_name: "User".into(), + }; + assert!(e.to_diagnostic().contains("User")); + assert!(e.to_diagnostic().contains("derive(Table)")); + } + + #[test] + fn single_missing_column_message() { + let e = ValidationError::MissingColumns { + struct_name: "User".into(), + missing: vec!["email".into()], + }; + let msg = e.to_diagnostic(); + assert!(msg.contains("email"), "message: {msg}"); + assert!(msg.contains("User"), "message: {msg}"); + } + + #[test] + fn multi_missing_columns_message() { + let e = ValidationError::MissingColumns { + struct_name: "User".into(), + missing: vec!["email".into(), "name".into()], + }; + let msg = e.to_diagnostic(); + assert!(msg.contains("email"), "message: {msg}"); + assert!(msg.contains("name"), "message: {msg}"); + } + + #[test] + fn single_table_not_registered_message() { + let e = ValidationError::TablesNotRegistered { + tables: vec!["ghosts".into()], + }; + assert!(e.to_diagnostic().contains("ghosts")); + assert!(e.to_diagnostic().contains("derive(Table)")); + } + + #[test] + fn unknown_column_message() { + let e = ValidationError::UnknownColumn { column: "d".into() }; + let msg = e.to_diagnostic(); + assert!(msg.contains("\"d\""), "message: {msg}"); + assert!(msg.contains("does not exist"), "message: {msg}"); + assert!(msg.contains("typo"), "message: {msg}"); + } +} diff --git a/hyperdb-compile-check/src/dry_run.rs b/hyperdb-compile-check/src/dry_run.rs new file mode 100644 index 0000000..b27a43c --- /dev/null +++ b/hyperdb-compile-check/src/dry_run.rs @@ -0,0 +1,104 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `LIMIT 0` dry-run helper. +//! +//! Wraps arbitrary user SQL in a CTE and runs it against the shared +//! `CompileTimeDb`, returning the `ResultSchema` without touching any rows. +//! +//! # Critical: query execution is lazy (Phase 0 S6) +//! +//! `Connection::execute_query()` does NOT run the query on the TCP transport. +//! The query only executes — and server errors / the `RowDescription` (schema) +//! only arrive — when `Rowset::next_chunk()` first pulls bytes. Therefore: +//! - `execute_query(sql).is_err()` alone always looks `Ok`. +//! - This helper calls `next_chunk()` once to force execution, then reads +//! `Rowset::schema()`. + +use hyperdb_api::{Error, Result, ResultSchema}; + +use crate::db::CompileTimeDb; + +/// Wrap `user_sql` in a `LIMIT 0` CTE and return the projected `ResultSchema`. +/// +/// Uses the `__hdb_q` CTE prefix to minimize collision with user-supplied CTE +/// names. +/// +/// # Errors +/// +/// Returns the Hyper error on any failure (callers branch on SQLSTATE via +/// [`crate::error_extract::classify`]). +pub fn dry_run(db: &mut CompileTimeDb, user_sql: &str) -> Result { + let wrapped = format!("WITH __hdb_q AS ({user_sql}) SELECT * FROM __hdb_q LIMIT 0"); + let mut rowset = db.conn.execute_query(&wrapped)?; + + // Force execution (Phase 0 S6): LIMIT 0 returns Ok(None) from next_chunk + // but populates the schema cache first. + rowset.next_chunk()?; + + rowset + .schema() + .ok_or_else(|| Error::Protocol("dry-run: schema missing after next_chunk".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::get_or_init; + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn dry_run_plain_select() { + let mut db = get_or_init().lock(); + db.conn + .execute_command("CREATE TABLE IF NOT EXISTS _dr_test (id BIGINT, name TEXT)") + .unwrap(); + let schema = dry_run(&mut db, "SELECT id, name FROM _dr_test").unwrap(); + let names: Vec<_> = schema + .columns() + .iter() + .map(hyperdb_api::ResultColumn::name) + .collect(); + assert_eq!(names, &["id", "name"]); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn dry_run_cte_wrapper() { + let mut db = get_or_init().lock(); + db.conn + .execute_command("CREATE TABLE IF NOT EXISTS _dr_cte (x INT, y TEXT)") + .unwrap(); + let schema = dry_run( + &mut db, + "WITH src AS (SELECT x, y FROM _dr_cte) SELECT * FROM src", + ) + .unwrap(); + assert_eq!(schema.column_count(), 2); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn dry_run_from_less_expression() { + let mut db = get_or_init().lock(); + let schema = dry_run(&mut db, "SELECT 1 AS a, 'x' AS b").unwrap(); + let names: Vec<_> = schema + .columns() + .iter() + .map(hyperdb_api::ResultColumn::name) + .collect(); + assert_eq!(names, &["a", "b"]); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn dry_run_bad_table_returns_error() { + let mut db = get_or_init().lock(); + let err = dry_run(&mut db, "SELECT * FROM _nonexistent_xyz").unwrap_err(); + assert_eq!( + err.sqlstate(), + Some("42P01"), + "expected undefined_table: {err}" + ); + } +} diff --git a/hyperdb-compile-check/src/error_extract.rs b/hyperdb-compile-check/src/error_extract.rs new file mode 100644 index 0000000..5fee330 --- /dev/null +++ b/hyperdb-compile-check/src/error_extract.rs @@ -0,0 +1,91 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! SQLSTATE-based Hyper error classification. +//! +//! Phase 0 spike S5 confirmed that Hyper returns PostgreSQL SQLSTATE codes as a +//! structured field on `Error::Server`. We branch on the **stable code**, not +//! fragile message text, so Hyper message wording changes don't break us. +//! +//! Relevant codes: +//! - `42P01` — undefined_table → extract the table name and seed-and-retry +//! - `42703` — undefined_column → report the column name verbatim +//! - `42601` — syntax_error → forward the message verbatim +//! - anything else → forward as `HyperError` + +use hyperdb_api::Error; + +/// Classification of a Hyper error for compile-time validation purposes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ErrorClass { + /// SQLSTATE `42P01`: a table is missing. Contains the table name extracted + /// from the error message. + MissingTable(String), + /// SQLSTATE `42703`: a column is missing. Contains the identifier verbatim. + MissingColumn(String), + /// SQLSTATE `42601`: SQL syntax error. The full message is forwarded. + SyntaxError(String), + /// Any other error (e.g. connection failure, internal error). + Other(String), +} + +/// Classify a `hyperdb_api::Error` by its SQLSTATE code. +pub fn classify(err: &Error) -> ErrorClass { + match err.sqlstate() { + Some("42P01") => { + ErrorClass::MissingTable(extract_quoted_identifier(&format!("{err}"), "table")) + } + Some("42703") => { + ErrorClass::MissingColumn(extract_quoted_identifier(&format!("{err}"), "column")) + } + Some("42601") => ErrorClass::SyntaxError(format!("{err}")), + _ => ErrorClass::Other(format!("{err}")), + } +} + +/// Extract a double-quoted or single-quoted identifier from a Hyper error +/// message, with a fallback to the full message. +/// +/// Phase 0 output for 42P01: `ERROR: table "ghosts" does not exist (42P01)` +/// Phase 0 output for 42703: `ERROR: unknown column 'ema1l' (42703)` +fn extract_quoted_identifier(message: &str, _kind: &str) -> String { + // Try double-quoted first ("ghosts"), then single-quoted ('ema1l'). + if let Some(name) = extract_between(message, '"', '"') { + return name; + } + if let Some(name) = extract_between(message, '\'', '\'') { + return name; + } + // Fallback: return the whole message so we never lose information. + message.to_owned() +} + +fn extract_between(s: &str, open: char, close: char) -> Option { + let start = s.find(open)? + open.len_utf8(); + let end = s[start..].find(close)? + start; + Some(s[start..end].to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_double_quoted() { + let msg = r#"ERROR: table "ghosts" does not exist (42P01)"#; + assert_eq!(extract_quoted_identifier(msg, "table"), "ghosts"); + } + + #[test] + fn extract_single_quoted() { + let msg = "ERROR: unknown column 'ema1l' (42703)"; + assert_eq!(extract_quoted_identifier(msg, "column"), "ema1l"); + } + + #[test] + fn extract_falls_back_to_full_message() { + let msg = "ERROR: something unquoted happened"; + let result = extract_quoted_identifier(msg, "table"); + assert_eq!(result, msg); + } +} diff --git a/hyperdb-compile-check/src/lib.rs b/hyperdb-compile-check/src/lib.rs new file mode 100644 index 0000000..822cedb --- /dev/null +++ b/hyperdb-compile-check/src/lib.rs @@ -0,0 +1,36 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Compile-time SQL validation logic for `hyperdb-api`. +//! +//! This is a **regular library crate** (not a proc-macro crate) so that its +//! validation logic — registry, dry-run, SQLSTATE classification, name-subset +//! diff — can be unit-tested with standard `cargo test` without `trybuild`. +//! +//! The proc-macro shells in `hyperdb-api-derive` call into this crate when the +//! `compile-time` feature is enabled. No `syn`/`quote`/`proc-macro2` types +//! appear in this crate's public API. +//! +//! # Architecture +//! +//! ```text +//! hyperdb-api-derive (proc-macro shell, thin) +//! └─(compile-time feature)─→ hyperdb-compile-check (this crate, testable) +//! └─→ hyperdb-api (HyperProcess, Connection, …) +//! ``` +//! +//! The three-crate split avoids the circular dependency that would arise from +//! adding `hyperdb-api` as a direct dep of `hyperdb-api-derive` (which +//! `hyperdb-api` already depends on). + +pub mod db; +pub mod diagnostic; +pub mod dry_run; +pub mod error_extract; +pub mod registry; +pub mod validate; + +pub use db::CompileTimeDb; +pub use diagnostic::ValidationError; +pub use registry::Registry; +pub use validate::{validate_query_as, validate_scalar_sql}; diff --git a/hyperdb-compile-check/src/registry.rs b/hyperdb-compile-check/src/registry.rs new file mode 100644 index 0000000..ce9c17c --- /dev/null +++ b/hyperdb-compile-check/src/registry.rs @@ -0,0 +1,182 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Table and struct registry for compile-time validation. +//! +//! `derive(Table) #[hyperdb(register)]` calls into this registry at macro +//! expansion time to record: +//! - The SQL `CREATE TABLE` statement for the table (for lazy seeding). +//! - The struct's field-name list (for the name-subset diff in `validate.rs`). +//! +//! Tables are seeded into the `CompileTimeDb` lazily: only when a `query_as!` +//! dry-run returns SQLSTATE `42P01` (undefined_table) do we seed the relevant +//! table and retry. This handles cross-file macro expansion ordering without +//! requiring a client-side SQL parser. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use parking_lot::Mutex; + +/// Information about a registered table derived from `#[derive(Table)]`. +#[derive(Debug, Clone)] +pub struct TableEntry { + /// The SQL `CREATE TABLE` statement emitted by `derive(Table)`. + pub create_sql: String, + /// Struct field names that map to columns (honoring `#[hyperdb(rename)]`, + /// excluding `#[hyperdb(index = N)]` fields). + pub fields: Vec, +} + +/// Global registry: **both** table name and struct ident → entry. +/// +/// Keyed by table name for the dry-run seed-and-retry path (Hyper reports the +/// SQL table name in 42P01 errors). Also indexed by struct name so that +/// `validate_query_as(struct_name, sql)` — which receives the Rust ident, not +/// the SQL name — can find the entry without knowing the table name upfront. +static REGISTRY: OnceLock>> = OnceLock::new(); + +/// Reverse map: struct ident → SQL table name. Populated alongside REGISTRY. +static STRUCT_TO_TABLE: OnceLock>> = OnceLock::new(); + +fn registry() -> &'static Mutex> { + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn struct_to_table() -> &'static Mutex> { + STRUCT_TO_TABLE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Register a table and its associated struct field list. +/// +/// Called by the `derive(Table) #[hyperdb(register)]` expansion. +/// - `struct_name`: the Rust struct ident (e.g. `"User"`), used by +/// `validate_query_as` which receives the ident from `query_as!(User, …)`. +/// - `table_name`: the SQL table name (e.g. `"users"`), used when Hyper +/// reports a missing table via SQLSTATE 42P01. +/// - `fields`: column names the struct expects in query results. +pub fn register( + struct_name: impl Into, + table_name: impl Into, + create_sql: impl Into, + fields: Vec, +) { + let struct_name = struct_name.into(); + let table_name = table_name.into(); + let entry = TableEntry { + create_sql: create_sql.into(), + fields, + }; + registry().lock().insert(table_name.clone(), entry.clone()); + struct_to_table() + .lock() + .insert(struct_name, table_name.clone()); +} + +/// Look up a registered entry by **SQL table name**. +pub fn get_by_table(table_name: &str) -> Option { + registry().lock().get(table_name).cloned() +} + +/// Look up a registered entry by **Rust struct ident**. +/// Returns `(table_name, entry)` so callers have the SQL name for seeding. +pub fn get_by_struct(struct_name: &str) -> Option<(String, TableEntry)> { + let table_name = struct_to_table().lock().get(struct_name).cloned()?; + let entry = registry().lock().get(&table_name).cloned()?; + Some((table_name, entry)) +} + +/// Returns true if the **SQL table name** is known to the registry. +pub fn contains(table_name: &str) -> bool { + registry().lock().contains_key(table_name) +} + +/// All registered table names (for diagnostics). +pub fn registered_names() -> Vec { + registry().lock().keys().cloned().collect() +} + +/// The public `Registry` type — a thin newtype that provides the seeding +/// interface against a live `CompileTimeDb`. Created from a lock guard by +/// `validate_query_as`. +#[derive(Debug)] +pub struct Registry; + +impl Registry { + /// Seed a registered table into `db` if it hasn't been created yet. + /// + /// Returns `true` if the table was seeded, `false` if unknown. + /// + /// # Errors + /// + /// Returns a Hyper error if the `CREATE TABLE` command fails. + pub fn seed_if_known( + table_name: &str, + db: &mut crate::db::CompileTimeDb, + ) -> hyperdb_api::Result { + let Some(entry) = get_by_table(table_name) else { + return Ok(false); + }; + db.conn.execute_command(&entry.create_sql)?; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests use unique table-name prefixes so they don't share global state + // and can run in parallel without races. + + #[test] + fn register_and_retrieve_by_table() { + register( + "RegTestUser", + "reg_test_users", + "CREATE TABLE reg_test_users (id BIGINT, name TEXT)", + vec!["id".into(), "name".into()], + ); + let entry = get_by_table("reg_test_users").expect("lookup by table name"); + assert_eq!(entry.fields, &["id", "name"]); + assert!(entry.create_sql.contains("reg_test_users")); + } + + #[test] + fn register_and_retrieve_by_struct() { + register( + "RegTestProfile", + "reg_test_profiles", + "CREATE TABLE reg_test_profiles (id BIGINT, bio TEXT)", + vec!["id".into(), "bio".into()], + ); + let (table_name, entry) = get_by_struct("RegTestProfile").expect("lookup by struct name"); + assert_eq!(table_name, "reg_test_profiles"); + assert_eq!(entry.fields, &["id", "bio"]); + } + + #[test] + fn contains_returns_false_for_unknown() { + assert!(!contains("reg_test_nonexistent_xyzzy")); + } + + #[test] + fn registration_ordering_independent() { + register( + "RegTestOrder", + "reg_test_orders", + "CREATE TABLE reg_test_orders (id BIGINT, user_id BIGINT)", + vec!["id".into(), "user_id".into()], + ); + register( + "RegTestCustomer", + "reg_test_customers", + "CREATE TABLE reg_test_customers (id BIGINT)", + vec!["id".into()], + ); + assert!(contains("reg_test_orders")); + assert!(contains("reg_test_customers")); + assert!(get_by_struct("RegTestOrder").is_some()); + assert!(get_by_struct("RegTestCustomer").is_some()); + } +} diff --git a/hyperdb-compile-check/src/validate.rs b/hyperdb-compile-check/src/validate.rs new file mode 100644 index 0000000..84fc22b --- /dev/null +++ b/hyperdb-compile-check/src/validate.rs @@ -0,0 +1,261 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `validate_query_as` — the single entry point for `query_as!` validation. +//! +//! Algorithm: +//! 1. Look up `struct_name` in the registry via `get_by_struct`; if absent → +//! `StructNotRegistered`. This returns the SQL table name alongside the entry +//! (struct ident ≠ SQL table name in general). +//! 2. Run the `LIMIT 0` dry-run. +//! 3. On success: compute name-subset diff (struct fields ⊆ result columns). +//! 4. On `42P01` (undefined_table): extract the SQL table name from the error, +//! seed from registry if known (then retry once), or emit `TablesNotRegistered`. +//! 5. On `42703` (undefined_column) or `42601` (syntax): forward as diagnostics. +//! +//! # Key design note: struct name vs. table name +//! +//! The registry is dual-indexed: by SQL table name (for 42P01 seed-and-retry, +//! which receives the SQL name from Hyper) and by Rust struct ident (for the +//! initial lookup, since `query_as!(User, …)` passes "User", not "users"). +//! `validate_query_as` always receives the struct ident; `Registry::seed_if_known` +//! always receives the SQL table name from Hyper's error. +//! +//! No `syn`/`quote`/`proc-macro2` types in this module's public API. + +use crate::db::get_or_init; +use crate::diagnostic::ValidationError; +use crate::dry_run::dry_run; +use crate::error_extract::{classify, ErrorClass}; +use crate::registry::{self, Registry}; + +/// Validate that `sql` is structurally compatible with `struct_name`. +/// +/// # Parameters +/// - `struct_name`: the Rust struct ident as a string (e.g. `"User"`). +/// Used for registry lookup via the struct→table index and for diagnostics. +/// - `sql`: the raw SQL string literal from `query_as!(T, "...")`. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] if validation fails — e.g. the struct is not +/// registered, SQL has a syntax error, referenced tables are not registered, or +/// the result schema is missing columns the struct requires. +pub fn validate_query_as(struct_name: &str, sql: &str) -> Result<(), ValidationError> { + // Step 1: look up by struct ident (not SQL table name — they differ). + let (_table_name, entry) = registry::get_by_struct(struct_name).ok_or_else(|| { + ValidationError::StructNotRegistered { + struct_name: struct_name.to_owned(), + } + })?; + + let mut db = get_or_init().lock(); + + // Step 2+4: dry-run with bounded seed-and-retry on 42P01. + let schema = run_dry_run_with_seed(sql, &mut db)?; + + drop(db); + + // Step 3: name-subset diff. + finish_name_check(struct_name, &entry.fields, &schema) +} + +/// Validate a scalar SQL string: runs the dry-run and checks the result +/// projects exactly one column. Used by `query_scalar!`. +/// +/// Does not require a struct registration — scalars project a single column +/// of a primitive type without mapping to a struct. However, tables referenced +/// by the SQL still need to be registered via `derive(Table) #[hyperdb(register)]` +/// for compile-time validation to work; unregistered tables produce a +/// `TablesNotRegistered` diagnostic. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] if the SQL is invalid, references an +/// unregistered table, or the result schema does not have exactly one column. +pub fn validate_scalar_sql(sql: &str) -> Result<(), ValidationError> { + let mut db = get_or_init().lock(); + + let schema = run_dry_run_with_seed(sql, &mut db)?; + + drop(db); + + let col_count = schema.column_count(); + if col_count != 1 { + return Err(ValidationError::HyperError { + message: format!( + "query_scalar! requires exactly one projected column, but the query projects {col_count}" + ), + }); + } + + Ok(()) +} + +/// Shared dry-run helper with bounded seed-and-retry on SQLSTATE 42P01. +/// +/// Loops up to `MAX_SEED_ROUNDS` times: on each 42P01, extracts the missing +/// table name, seeds it from the registry if known, and retries. This handles +/// multi-table queries (JOINs) where several registered tables need seeding +/// before the first `query_as!` invocation in a crate — without the loop, +/// only the first missing table would be seeded per call. +/// +/// Stops early on syntax errors, missing-column errors, or unregistered tables. +fn run_dry_run_with_seed( + sql: &str, + db: &mut crate::db::CompileTimeDb, +) -> Result { + // Bound to prevent infinite loops on pathological SQL (e.g., a self-join + // that repeatedly 42P01s on the same unregistered table after seeding). + const MAX_SEED_ROUNDS: usize = 8; + + for _ in 0..MAX_SEED_ROUNDS { + match dry_run(db, sql) { + Ok(schema) => return Ok(schema), + Err(e) => match classify(&e) { + ErrorClass::MissingTable(t) => match Registry::seed_if_known(&t, db) { + Ok(true) => {} // seeded successfully; loop iterates to retry the dry-run + Ok(false) => { + return Err(ValidationError::TablesNotRegistered { tables: vec![t] }) + } + Err(seed_err) => { + return Err(ValidationError::HyperError { + message: format!("{seed_err}"), + }) + } + }, + ErrorClass::SyntaxError(msg) => { + return Err(ValidationError::SqlSyntaxError { message: msg }) + } + ErrorClass::MissingColumn(col) => { + return Err(ValidationError::UnknownColumn { column: col }) + } + ErrorClass::Other(msg) => return Err(ValidationError::HyperError { message: msg }), + }, + } + } + + Err(ValidationError::HyperError { + message: format!( + "compile-time validation exceeded {MAX_SEED_ROUNDS} seed-and-retry rounds; \ + ensure all tables referenced by this query are registered via \ + `#[derive(Table)] #[hyperdb(register)]`" + ), + }) +} + +/// Check that every field in `struct_fields` appears as a column name in +/// `schema`. Extra columns in the result are fine (lenient-additions contract). +fn finish_name_check( + struct_name: &str, + struct_fields: &[String], + schema: &hyperdb_api::ResultSchema, +) -> Result<(), ValidationError> { + let result_cols: std::collections::HashSet<&str> = schema + .columns() + .iter() + .map(hyperdb_api::ResultColumn::name) + .collect(); + + let missing: Vec = struct_fields + .iter() + .filter(|f| !result_cols.contains(f.as_str())) + .cloned() + .collect(); + + if missing.is_empty() { + Ok(()) + } else { + Err(ValidationError::MissingColumns { + struct_name: struct_name.to_owned(), + missing, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_users() { + registry::register( + "User", + "users", + "CREATE TABLE IF NOT EXISTS users (id BIGINT, name TEXT, email TEXT)", + vec!["id".into(), "name".into(), "email".into()], + ); + } + + #[test] + fn struct_not_registered_error() { + let err = validate_query_as("Ghost", "SELECT 1").unwrap_err(); + assert!( + matches!(err, ValidationError::StructNotRegistered { .. }), + "expected StructNotRegistered, got: {err}" + ); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn valid_query_passes() { + setup_users(); + validate_query_as("User", "SELECT id, name, email FROM users").unwrap(); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn extra_column_in_result_is_ok() { + registry::register( + "SlimUser", + "slim_users", + "CREATE TABLE IF NOT EXISTS slim_users (id BIGINT, name TEXT, extra TEXT)", + vec!["id".into(), "name".into()], + ); + validate_query_as("SlimUser", "SELECT * FROM slim_users").unwrap(); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn missing_column_error() { + setup_users(); + let err = validate_query_as("User", "SELECT id, name FROM users").unwrap_err(); + assert!( + matches!(err, ValidationError::MissingColumns { .. }), + "expected MissingColumns, got: {err}" + ); + let msg = err.to_diagnostic(); + assert!( + msg.contains("email"), + "missing column name in message: {msg}" + ); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn seed_and_retry_on_missing_table() { + registry::register( + "Order", + "orders", + "CREATE TABLE IF NOT EXISTS orders (id BIGINT, total DOUBLE PRECISION)", + vec!["id".into(), "total".into()], + ); + validate_query_as("Order", "SELECT id, total FROM orders").unwrap(); + validate_query_as("Order", "SELECT id, total FROM orders").unwrap(); + } + + #[test] + #[ignore = "requires HYPERD_PATH; run manually"] + fn unregistered_table_in_sql_error() { + registry::register( + "Known", + "known", + "CREATE TABLE IF NOT EXISTS known (id BIGINT)", + vec!["id".into()], + ); + let err = validate_query_as("Known", "SELECT * FROM nonexistent_xyz").unwrap_err(); + assert!( + matches!(err, ValidationError::TablesNotRegistered { .. }), + "expected TablesNotRegistered, got: {err}" + ); + } +} diff --git a/release-please-config.json b/release-please-config.json index f69661e..a8a481c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,7 +16,8 @@ { "type": "generic", "path": "hyperdb-api-core/Cargo.toml" }, { "type": "generic", "path": "hyperdb-api/Cargo.toml" }, { "type": "generic", "path": "hyperdb-api-derive/Cargo.toml" }, - { "type": "generic", "path": "hyperdb-mcp/Cargo.toml" } + { "type": "generic", "path": "hyperdb-mcp/Cargo.toml" }, + { "type": "generic", "path": "hyperdb-compile-check/Cargo.toml" } ] } }