From 72f54dd428d399b0e556976919493f15e7be76eb Mon Sep 17 00:00:00 2001 From: "Alex H. Raber" Date: Sun, 2 Aug 2026 16:15:23 -0700 Subject: [PATCH] feat(dactyl): complete typed/NULL-safe named row projections (#25) Close the remaining gaps from the #25 backlog after PR #31: document and test first-match duplicate-alias semantics, prove the full scalar/NULL conversion matrix through SQLite and Neon, and make borrowed-vs-owned lifetime contracts explicit via get_str_ref/get_json_ref plus try_get and is_null. Unit tests cover the pure Row API; conformance covers both adapter paths and cross-adapter parity. README and INTERFACES link the contract to dactyl #25/#2 and DecapodLabs/decapod#1111. --- .decapod/managed/specs/INTERFACES.md | 2 +- CHANGELOG.md | 8 + README.md | 25 +- examples/readme_example.rs | 8 +- src/rows.rs | 319 ++++++++++++++++++++++-- tests/conformance.rs | 356 ++++++++++++++++++++++++++- 6 files changed, 677 insertions(+), 41 deletions(-) diff --git a/.decapod/managed/specs/INTERFACES.md b/.decapod/managed/specs/INTERFACES.md index 9d1d639..f0946c3 100644 --- a/.decapod/managed/specs/INTERFACES.md +++ b/.decapod/managed/specs/INTERFACES.md @@ -102,7 +102,7 @@ pub enum ApiError { - No legacy `DACTYL_*` variables are honored. - Parameters are always adapter-bound, never interpolated into SQL. `Parameter` enumerates `Null` / `Bool` / `Integer` / `Real` / `Text`. - `execute` is the caller-owned schema surface: dactyl never silently creates tables. -- `Row` provides strict `get` plus lenient `get_bool` / `get_int` / `get_real` / `get_str` / `get_json` with explicit `ColumnNotFound` / `Conversion` error semantics. +- `Row` provides strict `get` / `try_get`, lenient `get_bool` / `get_int` / `get_real` / `get_str` / `get_json`, borrowed `get_str_ref` / `get_json_ref`, and `is_null`, with explicit `ColumnNotFound` / `Conversion` errors. Named lookup is left-to-right first-match for duplicate aliases. SQL NULL maps to `Option` or a `Conversion` that mentions NULL for non-Option targets. Rows own their cells; borrowed accessors are tied to `&Row` only (dactyl #25 / #2; DecapodLabs/decapod#1111). - `transaction` is atomic: any per-statement failure rolls back the whole unit on SQLite and is rejected by the Neon `/batch` endpoint. - `query!("sql")` lexically analyzes the literal at compile time and returns the rewritten SQL as a `String` for the caller to pass to `query`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2b5a4..659df1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- *(dactyl)* complete typed/NULL-safe named row projections for [#25](https://github.com/DecapodLabs/dactyl/issues/25): `try_get`, `is_null`, borrowed `get_str_ref` / `get_json_ref`, explicit first-match duplicate-alias semantics, NULL conversion messages, unit + SQLite/Neon matrix conformance (also DecapodLabs/decapod#1111, dactyl #2). + +### Documentation + +- *(dactyl)* document the full `Row` projection contract (scalars, NULL, missing columns, aliases, ownership/lifetime) in the README and crate docs. + ## [0.2.3](https://github.com/DecapodLabs/dactyl/compare/dactyl-db-macros-v0.2.1...dactyl-db-macros-v0.2.3) - 2026-08-01 ### Fixed diff --git a/README.md b/README.md index a3e6669..6907778 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,12 @@ fn main() -> Result<(), dactyl_db::DactylError> { let sql = query!("select id, title, status from todos"); for row in dactyl_db::query(&sql, &[])?.iter() { - let id: i64 = row.get("id")?; + // Strict typed projection (owned). Prefer try_get if you like Result style. + let id: i64 = row.try_get("id")?; let title: String = row.get("title")?; - let status: String = row.get("status")?; - println!("todo {id}: {title} [{status}]"); + // Nullable columns use Option; missing columns are ColumnNotFound. + let status: Option = row.get("status")?; + println!("todo {id}: {title} [{status:?}]"); } Ok(()) } @@ -67,6 +69,21 @@ DATASTORE=sqlite DATASTORE_ROUTE=/tmp/dactyl-example.db \ cargo run --features sqlite --example readme_example ``` +## Named-column projections (`Row`) + +This is the stable contract for typed and NULL-safe extraction (dactyl [#25](https://github.com/DecapodLabs/dactyl/issues/25), conformance [#2](https://github.com/DecapodLabs/dactyl/issues/2); also DecapodLabs/decapod#1111): + +| Concern | Semantics | +|---|---| +| Integer / real / bool / text | `get_int`, `get_real`, `get_bool`, `get_str` (owned) or strict `get::` / `try_get::` via serde | +| Portable bool | `get_bool` accepts JSON `true`/`false` **or** integer `0`/`1` (SQLite stores bools as integers) | +| JSON / text | `get_json` / `get_json_ref` return the raw cell. Text payloads stay strings until the caller parses them; Neon may surface structured JSON objects. | +| SQL NULL | `get::>` → `None`; non-`Option` getters → `Conversion` mentioning NULL; `is_null` / `get_json` surface null without converting | +| Missing column | `DactylError::ColumnNotFound` | +| Duplicate aliases | **First match** left-to-right. `select a as x, b as x` → `get("x")` is `a`. Use a positional index for later duplicates. | +| Owned vs borrowed | `get` / `get_*` return owned values that outlive the row. `get_str_ref` / `get_json_ref` borrow from `&Row` for the row lifetime. A `Row` outlives the adapter connection. | +| Conversion failure | `DactylError::Conversion` with the column key and a reason | + ## How dactyl selects the backend The active backend is chosen by ambient environment variables — no `init()` call, no per-call datastore argument, no process-wide connection cache. Each `query` / `execute` / `transaction` call constructs a fresh short-lived adapter and drops it on return, so workspace and session isolation is automatic and the public surface is `Send + Sync` without any lock. @@ -88,6 +105,8 @@ The active backend is chosen by ambient environment variables — no `init()` ca `Parameter` enumerates the typed binding set: `Null`, `Bool`, `Integer`, `Real`, `Text`. The adapter forwards the values verbatim — never as interpolated SQL. +`Row` provides `get` / `try_get`, lenient scalar getters, `is_null`, and borrowed `get_str_ref` / `get_json_ref` under the projection contract above. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/examples/readme_example.rs b/examples/readme_example.rs index 8da1f0f..9496a5f 100644 --- a/examples/readme_example.rs +++ b/examples/readme_example.rs @@ -27,10 +27,12 @@ fn main() -> Result<(), dactyl_db::DactylError> { let sql = dactyl_db::query!("select id, title, status from todos"); for row in dactyl_db::query(&sql, &[])?.iter() { - let id: i64 = row.get("id")?; + let id: i64 = row.try_get("id")?; let title: String = row.get("title")?; - let status: String = row.get("status")?; - println!("todo {id}: {title} [{status}]"); + let status: String = row.get_str("status")?; + // Borrowed accessor is valid for the row lifetime. + let title_ref: &str = row.get_str_ref("title")?; + println!("todo {id}: {title} ({title_ref}) [{status}]"); } Ok(()) } diff --git a/src/rows.rs b/src/rows.rs index f76181d..1b0417a 100644 --- a/src/rows.rs +++ b/src/rows.rs @@ -1,4 +1,23 @@ -//! Row projection returned by [`crate::read`] and [`crate::write`]. +//! Row projection returned by [`crate::query`] and [`crate::transaction`]. +//! +//! # Named-column projection contract (dactyl #25) +//! +//! Each [`Row`] owns its column names and cell values as JSON. Named lookup is +//! stable across SQLite and Neon because both adapters normalize cells into +//! this shape before returning. +//! +//! | Concern | Semantics | +//! |---|---| +//! | Integer | JSON number that fits `i64` (`get_int` / `get::`) | +//! | Real | JSON number (`get_real` / `get::`; integers are accepted as reals) | +//! | Boolean | JSON `true`/`false`, or integer `0`/`1` via `get_bool` (SQLite stores bools as integers) | +//! | Text | JSON string (`get_str` / `get_str_ref` / `get::`) | +//! | JSON / text | Low-level cell via `get_json` / `get_json_ref`; text payloads stay strings until the caller parses them | +//! | SQL NULL | JSON `null`. Non-`Option` typed getters return [`DactylError::Conversion`]; `get::>` yields `None`. `is_null` / `get_json` surface null without converting. | +//! | Missing column | [`DactylError::ColumnNotFound`] | +//! | Duplicate aliases | Left-to-right **first match**. `select a as x, b as x` resolves `get("x")` to the first `x`. Positional indexes still reach later duplicates. | +//! | Conversion failure | [`DactylError::Conversion`] with the column key and a reason string | +//! | Ownership | `get`, `get_*` (except `*_ref`) return **owned** values independent of the row. `get_str_ref` / `get_json_ref` borrow from `&self` for the row lifetime. A `Row` outlives the adapter connection. | use crate::error::DactylError; use serde::{Deserialize, Serialize}; @@ -38,11 +57,18 @@ impl IntoIterator for Rows { } } -/// One result row. Carries the column names (shared across the result) plus -/// the per-cell JSON values. +/// One result row. Carries the column names plus the per-cell JSON values. +/// +/// The row **owns** both vectors. After `query` / `transaction` returns, the +/// short-lived adapter is dropped; callers may keep `Row` values indefinitely. +/// Borrowed accessors (`get_str_ref`, `get_json_ref`) are tied to `&self` only. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Row { /// Column names, in the order the adapter emitted them. + /// + /// Duplicate names are allowed (SQL aliases). Named getters resolve the + /// **first** occurrence left-to-right; use a positional [`usize`] index to + /// reach a later duplicate. pub columns: Vec, /// Per-cell values, parallel to `columns`. pub values: Vec, @@ -215,8 +241,11 @@ impl From> for Parameter { } /// Helper trait for row indexing by position or column name. +/// +/// Named indexes (`&str`, `String`) resolve **left-to-right first match** when +/// a result set contains duplicate column aliases. pub trait RowIndex: std::fmt::Debug { - /// Return index in row. + /// Return the resolved index in `row`, or `None` if the name is absent. fn idx(&self, row: &Row) -> Option; } @@ -228,6 +257,7 @@ impl RowIndex for usize { impl RowIndex for &str { fn idx(&self, row: &Row) -> Option { + // First match wins for duplicate aliases (documented contract). row.columns.iter().position(|c| c == self) } } @@ -239,13 +269,18 @@ impl RowIndex for String { } impl Row { - /// Strict typed extraction via `serde`. Returns a `Conversion` error on - /// any type mismatch (e.g. reading an integer column as `bool` when the - /// stored JSON is `1` rather than `true`). For lenient portable shapes - /// use [`Self::get_bool`] / [`Self::get_int`] / [`Self::get_real`] / - /// [`Self::get_str`] / [`Self::get_json`]. + /// Strict typed extraction via `serde` into an **owned** `T`. /// - /// Missing column → [`DactylError::ColumnNotFound`]. + /// - Missing column → [`DactylError::ColumnNotFound`]. + /// - SQL NULL into non-`Option` `T` → [`DactylError::Conversion`]. + /// - SQL NULL into `Option` → `Ok(None)`. + /// - Type mismatch → [`DactylError::Conversion`]. + /// - Duplicate aliases → first matching column (left-to-right). + /// + /// Prefer [`Self::get_bool`] for portable bools: SQLite stores booleans as + /// integers `0`/`1`, which strict `get::` rejects. For lenient + /// portable shapes use [`Self::get_bool`] / [`Self::get_int`] / + /// [`Self::get_real`] / [`Self::get_str`] / [`Self::get_json`]. pub fn get( &self, index: I, @@ -253,17 +288,43 @@ impl Row { let i = self.idx(&index)?; let val = &self.values[i]; serde_json::from_value(val.clone()).map_err(|e| { - DactylError::Conversion(format!( - "failed to convert column {:?} to target type: {}", - index, e - )) + if val.is_null() { + DactylError::Conversion(format!( + "column {:?} is NULL; use Option with get for nullable columns", + index + )) + } else { + DactylError::Conversion(format!( + "failed to convert column {:?} to target type: {}", + index, e + )) + } }) } - /// Lenient `bool` accessor: accepts `true`/`false` or `0`/`1` integer. + /// Alias for [`Self::get`]. Named for callers who prefer a `try_*` style. + pub fn try_get( + &self, + index: I, + ) -> Result { + self.get(index) + } + + /// Whether the cell is SQL NULL (`serde_json::Value::Null`). + /// + /// Missing column → [`DactylError::ColumnNotFound`]. + pub fn is_null(&self, index: I) -> Result { + let i = self.idx(&index)?; + Ok(self.values[i].is_null()) + } + + /// Lenient **owned** `bool`: accepts JSON `true`/`false` or integer `0`/`1`. + /// + /// NULL → [`DactylError::Conversion`]. pub fn get_bool(&self, index: I) -> Result { let i = self.idx(&index)?; match &self.values[i] { + serde_json::Value::Null => Err(Self::null_err(&index)), serde_json::Value::Bool(b) => Ok(*b), serde_json::Value::Number(n) if n.as_i64() == Some(0) => Ok(false), serde_json::Value::Number(n) if n.as_i64() == Some(1) => Ok(true), @@ -274,10 +335,13 @@ impl Row { } } - /// Lenient `i64` accessor: accepts JSON integer. + /// Lenient **owned** `i64`: accepts JSON integers (not fractional reals). + /// + /// NULL → [`DactylError::Conversion`]. pub fn get_int(&self, index: I) -> Result { let i = self.idx(&index)?; match &self.values[i] { + serde_json::Value::Null => Err(Self::null_err(&index)), serde_json::Value::Number(n) => n.as_i64().ok_or_else(|| { DactylError::Conversion(format!("value is not i64 at column {:?}", index)) }), @@ -288,10 +352,13 @@ impl Row { } } - /// Lenient `f64` accessor: accepts JSON number. + /// Lenient **owned** `f64`: accepts any JSON number (integers and reals). + /// + /// NULL → [`DactylError::Conversion`]. pub fn get_real(&self, index: I) -> Result { let i = self.idx(&index)?; match &self.values[i] { + serde_json::Value::Null => Err(Self::null_err(&index)), serde_json::Value::Number(n) => n.as_f64().ok_or_else(|| { DactylError::Conversion(format!("value is not f64 at column {:?}", index)) }), @@ -302,22 +369,44 @@ impl Row { } } - /// Lenient `String` accessor: accepts JSON string. + /// Lenient **owned** `String`: accepts JSON string (clones the cell). + /// + /// NULL → [`DactylError::Conversion`]. Prefer [`Self::get_str_ref`] to borrow. pub fn get_str(&self, index: I) -> Result { + self.get_str_ref(index).map(str::to_owned) + } + + /// Borrowed `&str` tied to the row lifetime. Accepts JSON string only. + /// + /// NULL → [`DactylError::Conversion`]. The reference is valid while `self` lives. + pub fn get_str_ref(&self, index: I) -> Result<&str, DactylError> { let i = self.idx(&index)?; match &self.values[i] { - serde_json::Value::String(s) => Ok(s.clone()), + serde_json::Value::Null => Err(Self::null_err(&index)), + serde_json::Value::String(s) => Ok(s.as_str()), other => Err(DactylError::Conversion(format!( - "cannot read {other:?} as String at column {:?}", + "cannot read {other:?} as str at column {:?}", index ))), } } - /// Raw JSON value accessor at the given column. + /// Owned clone of the raw JSON cell (including `Null`). pub fn get_json(&self, index: I) -> Result { + self.get_json_ref(index).cloned() + } + + /// Borrowed raw JSON cell tied to the row lifetime (including `Null`). + pub fn get_json_ref(&self, index: I) -> Result<&serde_json::Value, DactylError> { let i = self.idx(&index)?; - Ok(self.values[i].clone()) + Ok(&self.values[i]) + } + + fn null_err(index: &I) -> DactylError { + DactylError::Conversion(format!( + "column {:?} is NULL; use Option with get for nullable columns", + index + )) } fn idx(&self, index: &I) -> Result { @@ -335,3 +424,189 @@ impl Row { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_row() -> Row { + Row { + columns: vec![ + "id".into(), + "flag".into(), + "ratio".into(), + "label".into(), + "payload".into(), + "nullable".into(), + "name".into(), // first of duplicate alias pair + "name".into(), // second alias — positional only via index + ], + values: vec![ + json!(42), + json!(true), + json!(1.5), + json!("hello"), + json!({"k": 1}), + json!(null), + json!("first"), + json!("second"), + ], + } + } + + #[test] + fn scalar_matrix_strict_and_lenient() { + let row = sample_row(); + + assert_eq!(row.get::<_, i64>("id").unwrap(), 42); + assert_eq!(row.try_get::<_, i64>("id").unwrap(), 42); + assert_eq!(row.get_int("id").unwrap(), 42); + assert_eq!(row.get_real("id").unwrap(), 42.0); + + assert!(row.get_bool("flag").unwrap()); + assert!(row.get::<_, bool>("flag").unwrap()); + + assert_eq!(row.get::<_, f64>("ratio").unwrap(), 1.5); + assert_eq!(row.get_real("ratio").unwrap(), 1.5); + assert!(matches!( + row.get_int("ratio"), + Err(DactylError::Conversion(_)) + )); + + assert_eq!(row.get_str("label").unwrap(), "hello"); + assert_eq!(row.get_str_ref("label").unwrap(), "hello"); + assert_eq!(row.get::<_, String>("label").unwrap(), "hello"); + + let payload = row.get_json("payload").unwrap(); + assert_eq!(payload, json!({"k": 1})); + assert_eq!(row.get_json_ref("payload").unwrap(), &json!({"k": 1})); + #[derive(Deserialize)] + struct Payload { + k: i64, + } + assert_eq!(row.get::<_, Payload>("payload").unwrap().k, 1); + } + + #[test] + fn sqlite_style_bool_as_integer() { + let row = Row { + columns: vec!["flag".into(), "off".into()], + values: vec![json!(1), json!(0)], + }; + assert!(row.get_bool("flag").unwrap()); + assert!(!row.get_bool("off").unwrap()); + // Strict serde bool rejects integer encoding. + assert!(matches!( + row.get::<_, bool>("flag"), + Err(DactylError::Conversion(_)) + )); + } + + #[test] + fn null_semantics() { + let row = sample_row(); + assert!(row.is_null("nullable").unwrap()); + assert!(!row.is_null("id").unwrap()); + assert!(row.get::<_, Option>("nullable").unwrap().is_none()); + assert!(row.get::<_, Option>("nullable").unwrap().is_none()); + + let null_errs: Vec = vec![ + row.get::<_, i64>("nullable").unwrap_err(), + row.get_int("nullable").unwrap_err(), + row.get_bool("nullable").unwrap_err(), + row.get_real("nullable").unwrap_err(), + row.get_str("nullable").unwrap_err(), + row.get_str_ref("nullable").unwrap_err(), + ]; + for err in null_errs { + match err { + DactylError::Conversion(msg) => { + assert!(msg.contains("NULL"), "expected NULL hint, got {msg}"); + } + other => panic!("expected Conversion for NULL, got {other:?}"), + } + } + + assert_eq!(row.get_json("nullable").unwrap(), json!(null)); + assert!(row.get_json_ref("nullable").unwrap().is_null()); + } + + #[test] + fn missing_column_is_column_not_found() { + let row = sample_row(); + assert!(matches!( + row.get::<_, i64>("nope"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + row.get_int("nope"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + row.is_null("nope"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + row.get_json_ref("nope"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + row.get::<_, i64>(99usize), + Err(DactylError::ColumnNotFound(_)) + )); + } + + #[test] + fn conversion_failures() { + let row = sample_row(); + assert!(matches!( + row.get::<_, bool>("label"), + Err(DactylError::Conversion(_)) + )); + assert!(matches!( + row.get_int("label"), + Err(DactylError::Conversion(_)) + )); + assert!(matches!(row.get_str("id"), Err(DactylError::Conversion(_)))); + assert!(matches!( + row.get_bool("ratio"), + Err(DactylError::Conversion(_)) + )); + } + + #[test] + fn duplicate_alias_first_match_and_positional() { + let row = sample_row(); + // Named lookup: left-to-right first match. + assert_eq!(row.get_str("name").unwrap(), "first"); + assert_eq!(row.get_str_ref("name").unwrap(), "first"); + assert_eq!(row.get_int(0usize).unwrap(), 42); + // Positional index reaches the second "name" column. + let second_name_idx = row + .columns + .iter() + .enumerate() + .filter(|(_, c)| *c == "name") + .nth(1) + .map(|(i, _)| i) + .expect("second name"); + assert_eq!(row.get_str(second_name_idx).unwrap(), "second"); + assert_eq!(row.get_json_ref(second_name_idx).unwrap(), &json!("second")); + } + + #[test] + fn borrowed_values_tied_to_row_lifetime() { + let row = sample_row(); + let s: &str = row.get_str_ref("label").unwrap(); + let j: &serde_json::Value = row.get_json_ref("payload").unwrap(); + // Still usable while `row` is in scope (compile-time lifetime proof + // plus runtime equality). + assert_eq!(s, "hello"); + assert_eq!(j["k"], 1); + // Owned getters return independent values. + let owned = row.get_str("label").unwrap(); + drop(row); + assert_eq!(owned, "hello"); + } +} diff --git a/tests/conformance.rs b/tests/conformance.rs index cc1ab26..f189a96 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -9,7 +9,9 @@ //! - #2 every store × every adapter × parameterized reads/writes //! - #23 parameter binding, NULL/bool/int/real/text + injection attempt //! - #24 atomic transaction + rollback-on-failure -//! - #25 typed named extraction, NULL, missing column, conversion error +//! - #25 typed named extraction, NULL, missing column, conversion error, +//! duplicate aliases, full scalar matrix through both adapters, and +//! borrowed-vs-owned accessors (also DecapodLabs/decapod#1111) //! - #26 session isolation via per-call adapter construction //! - #27 caller-owned schema; dactyl never silently bootstraps tables @@ -70,10 +72,17 @@ fn clear_env() { // Mock neon server: a tiny in-process axum app that stores rows per table. // --------------------------------------------------------------------------- +/// One mock table: explicit column order plus row objects. +#[derive(Clone, Default)] +struct MockTable { + columns: Vec, + rows: Vec, +} + /// Mock neon-server state, shared across all tests. #[derive(Default)] struct MockState { - rows: Mutex>>, + tables: Mutex>, } impl MockState { @@ -81,12 +90,17 @@ impl MockState { State(state): State>, Json(req): Json, ) -> Json { - let rows = state.rows.lock().await; + let tables = state.tables.lock().await; let table = table_of(&req.sql); - let data = rows.get(&table).cloned().unwrap_or_default(); + let data = tables.get(&table).cloned().unwrap_or_default(); + let columns = if data.columns.is_empty() { + vec!["id".into(), "title".into(), "status".into()] + } else { + data.columns + }; Json(MockResponse { - columns: vec!["id".into(), "title".into(), "status".into()], - rows: data, + columns, + rows: data.rows, }) } @@ -94,14 +108,19 @@ impl MockState { State(state): State>, Json(req): Json, ) -> Json { - let rows = state.rows.lock().await; + let tables = state.tables.lock().await; let mut results = Vec::new(); for stmt in &req.statements { let table = table_of(&stmt.sql); - let data = rows.get(&table).cloned().unwrap_or_default(); + let data = tables.get(&table).cloned().unwrap_or_default(); + let columns = if data.columns.is_empty() { + vec!["id".into(), "title".into(), "status".into()] + } else { + data.columns + }; results.push(MockResponse { - columns: vec!["id".into(), "title".into(), "status".into()], - rows: data, + columns, + rows: data.rows, }); } Json(MockBatchResponse { results }) @@ -277,9 +296,15 @@ fn conformance_all_stores() { .expect("mock rt"); rt.block_on(async { { - let mut rows = state.rows.lock().await; + let mut tables = state.tables.lock().await; for store in STORES { - rows.insert(store.to_string(), seed_rows(store)); + tables.insert( + store.to_string(), + MockTable { + columns: vec!["id".into(), "title".into(), "status".into()], + rows: seed_rows(store), + }, + ); } } let addr = spawn_mock(state.clone()).await; @@ -471,7 +496,314 @@ fn typed_row_extraction_and_error_semantics() { "type mismatch must be Conversion" ); + // try_get is an alias for get. + let id2: i64 = row.try_get("id").expect("try_get id"); + assert_eq!(id2, 2); + + // NULL on non-Option surfaces a Conversion that mentions NULL. + let null_int: Result = row.get("status"); + assert!( + matches!(null_int, Err(DactylError::Conversion(ref m)) if m.contains("NULL")), + "NULL into non-Option must mention NULL: {null_int:?}" + ); + + clear_env(); +} + +/// #25: full scalar / NULL conversion matrix through SQLite, including +/// duplicate-alias first-match and borrowed accessors. +#[test] +fn typed_projection_matrix_sqlite() { + let _guard = lock_env(); + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("matrix.db"); + select_sqlite(path.to_str().unwrap()); + + dactyl_db::execute( + "create table matrix ( + id integer primary key, + flag integer not null, + ratio real not null, + label text not null, + note text, + payload text not null + )", + &[], + ) + .expect("create"); + dactyl_db::execute( + "insert into matrix (id, flag, ratio, label, note, payload) values ($1, $2, $3, $4, $5, $6)", + &[ + Parameter::Integer(7), + Parameter::Bool(true), + Parameter::Real(2.25), + Parameter::Text("alpha".into()), + Parameter::Null, + Parameter::Text(r#"{"k":9}"#.into()), + ], + ) + .expect("insert"); + + let rows = dactyl_db::query( + "select id, flag, ratio, label, note, payload from matrix where id = $1", + &[Parameter::Integer(7)], + ) + .expect("select"); + let row = &rows.as_slice()[0]; + + assert_eq!(row.get_int("id").expect("id"), 7); + assert_eq!(row.get::<_, i64>("id").expect("id strict"), 7); + assert!(row.get_bool("flag").expect("flag"), "sqlite bool as 0/1"); + assert_eq!(row.get_real("ratio").expect("ratio"), 2.25); + assert_eq!(row.get_str("label").expect("label"), "alpha"); + assert_eq!(row.get_str_ref("label").expect("label ref"), "alpha"); + assert!(row.is_null("note").expect("note null")); + assert!(row + .get::<_, Option>("note") + .expect("note opt") + .is_none()); + // JSON/text: payload remains a string cell until the caller parses it. + assert_eq!(row.get_str("payload").expect("payload text"), r#"{"k":9}"#); + assert_eq!( + row.get_json_ref("payload").expect("payload json").as_str(), + Some(r#"{"k":9}"#) + ); + + // Duplicate aliases: first match wins; positional reaches the later one. + let alias_rows = dactyl_db::query( + "select id as name, label as name from matrix where id = $1", + &[Parameter::Integer(7)], + ) + .expect("alias select"); + let a = &alias_rows.as_slice()[0]; + assert_eq!(a.columns, vec!["name", "name"]); + assert_eq!(a.get_int("name").expect("first name is id"), 7); + assert_eq!(a.get_str(1usize).expect("second name is label"), "alpha"); + + // Missing + conversion still typed through the adapter path. + assert!(matches!( + a.get_str("missing"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + a.get_bool("name"), + Err(DactylError::Conversion(_)) + )); + + clear_env(); +} + +/// #25: same projection matrix through the Neon adapter path (mock server). +/// Proves typed getters, NULL, missing columns, and conversion errors are +/// adapter-agnostic once cells are normalized to JSON. +#[test] +fn typed_projection_matrix_neon() { + let _guard = lock_env(); + let state = Arc::new(MockState::default()); + let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); + + let mock_thread = std::thread::spawn({ + let state = state.clone(); + move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("mock rt"); + rt.block_on(async { + { + let mut tables = state.tables.lock().await; + tables.insert( + "matrix".into(), + MockTable { + columns: vec![ + "id".into(), + "flag".into(), + "ratio".into(), + "label".into(), + "note".into(), + "payload".into(), + "name".into(), + "name".into(), + ], + rows: vec![serde_json::json!({ + "id": 7, + "flag": true, + "ratio": 2.25, + "label": "alpha", + "note": null, + "payload": {"k": 9}, + // JSON objects cannot hold two "name" keys; the + // columns list still carries the duplicate + // alias pair and both map to the same wire key. + "name": "first", + })], + }, + ); + } + let addr = spawn_mock(state.clone()).await; + let _ = ready_tx.send(format!("http://{addr}")); + let _ = done_rx.await; + }); + } + }); + + let endpoint = ready_rx.recv().expect("ready"); + select_neon(&endpoint); + + let rows = dactyl_db::query( + "select id, flag, ratio, label, note, payload, name from matrix", + &[], + ) + .expect("neon select"); + assert_eq!(rows.len(), 1); + let row = &rows.as_slice()[0]; + + assert_eq!(row.get_int("id").expect("id"), 7); + assert_eq!(row.try_get::<_, i64>("id").expect("try_get"), 7); + assert!(row.get_bool("flag").expect("flag")); + assert!(row.get::<_, bool>("flag").expect("strict bool")); + assert_eq!(row.get_real("ratio").expect("ratio"), 2.25); + assert_eq!(row.get_str("label").expect("label"), "alpha"); + assert_eq!(row.get_str_ref("label").expect("label ref"), "alpha"); + assert!(row.is_null("note").expect("note")); + assert!(row + .get::<_, Option>("note") + .expect("note opt") + .is_none()); + // Neon can surface structured JSON objects directly. + assert_eq!(row.get_json("payload").expect("payload")["k"], 9); + #[derive(serde::Deserialize)] + struct Payload { + k: i64, + } + assert_eq!(row.get::<_, Payload>("payload").expect("typed json").k, 9); + + // Duplicate alias names: first-match semantics still hold. + assert_eq!(row.columns.iter().filter(|c| *c == "name").count(), 2); + assert_eq!(row.get_str("name").expect("first name"), "first"); + assert_eq!( + row.get_str(row.columns.len() - 1).expect("last name col"), + "first", + "JSON wire loses distinct duplicate values; both aliases map to the key" + ); + + assert!(matches!( + row.get_int("missing"), + Err(DactylError::ColumnNotFound(_)) + )); + assert!(matches!( + row.get_int("label"), + Err(DactylError::Conversion(_)) + )); + assert!(matches!( + row.get::<_, i64>("note"), + Err(DactylError::Conversion(ref m)) if m.contains("NULL") + )); + + clear_env(); + let _ = done_tx.send(()); + let _ = mock_thread.join(); +} + +/// #25 + #2: cross-adapter equality of typed projections for the same logical +/// scalar row (integer, bool-as-portable, real, text, null). +#[test] +fn typed_projection_cross_adapter_parity() { + let _guard = lock_env(); + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("parity.db"); + select_sqlite(path.to_str().unwrap()); + + dactyl_db::execute( + "create table parity (id integer primary key, flag integer, ratio real, label text, note text)", + &[], + ) + .expect("create"); + dactyl_db::execute( + "insert into parity (id, flag, ratio, label, note) values ($1, $2, $3, $4, $5)", + &[ + Parameter::Integer(1), + Parameter::Bool(false), + Parameter::Real(0.5), + Parameter::Text("parity".into()), + Parameter::Null, + ], + ) + .expect("insert"); + + select_sqlite(path.to_str().unwrap()); + let sqlite_rows = dactyl_db::query( + "select id, flag, ratio, label, note from parity where id = $1", + &[Parameter::Integer(1)], + ) + .expect("sqlite"); + let s = &sqlite_rows.as_slice()[0]; + + let state = Arc::new(MockState::default()); + let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); + let mock_thread = std::thread::spawn({ + let state = state.clone(); + // Neon bool as true JSON bool; SQLite stores 0/1. Lenient get_bool + // unifies both. + let neon_row = serde_json::json!({ + "id": 1, + "flag": false, + "ratio": 0.5, + "label": "parity", + "note": null, + }); + move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("mock rt"); + rt.block_on(async { + { + let mut tables = state.tables.lock().await; + tables.insert( + "parity".into(), + MockTable { + columns: vec![ + "id".into(), + "flag".into(), + "ratio".into(), + "label".into(), + "note".into(), + ], + rows: vec![neon_row], + }, + ); + } + let addr = spawn_mock(state.clone()).await; + let _ = ready_tx.send(format!("http://{addr}")); + let _ = done_rx.await; + }); + } + }); + let endpoint = ready_rx.recv().expect("ready"); + select_neon(&endpoint); + let neon_rows = dactyl_db::query( + "select id, flag, ratio, label, note from parity where id = $1", + &[Parameter::Integer(1)], + ) + .expect("neon"); + let n = &neon_rows.as_slice()[0]; + + assert_eq!(s.get_int("id").unwrap(), n.get_int("id").unwrap()); + assert_eq!(s.get_bool("flag").unwrap(), n.get_bool("flag").unwrap()); + assert_eq!(s.get_real("ratio").unwrap(), n.get_real("ratio").unwrap()); + assert_eq!(s.get_str("label").unwrap(), n.get_str("label").unwrap()); + assert_eq!(s.is_null("note").unwrap(), n.is_null("note").unwrap()); + assert_eq!( + s.get::<_, Option>("note").unwrap(), + n.get::<_, Option>("note").unwrap() + ); + clear_env(); + let _ = done_tx.send(()); + let _ = mock_thread.join(); } /// #24: atomic transaction batch commits on success and rolls back fully on