Summary
The current FromRow trait in hyperdb-api provides struct mapping for query results, but every consumer must hand-write a positional impl. This is idiomatic but has rough edges — proposing a #[derive(FromRow)] proc macro and named-column access to address them.
Current state
FromRow is defined in hyperdb-api/src/result.rs:744. Typical usage:
struct TestUser { id: i32, name: String, score: f64 }
impl FromRow for TestUser {
fn from_row(row: &Row) -> hyperdb_api::Result<Self> {
Ok(TestUser {
id: row.get::<i32>(0).ok_or_else(|| hyperdb_api::Error::new("NULL id"))?,
name: row.get::<String>(1).unwrap_or_default(),
score: row.get::<f64>(2).unwrap_or(0.0),
})
}
}
Reference example: hyperdb-api/tests/remaining_features_tests.rs:244-309.
Pain points
- Positional indices couple the struct to column order in the SQL. Reordering a
SELECT silently swaps fields. sqlx's row.try_get("name") is safer.
- Boilerplate scales linearly with column count. A 10-column struct is 10 nearly-identical lines of
row.get::<T>(n).ok_or_else(...).
- NULL handling is ad-hoc. Each impl independently decides between
.unwrap_or_default(), .unwrap_or(0.0), or error-on-NULL. Easy to be inconsistent; unwrap_or_default silently swallows real NULLs.
- No compile-time SQL ↔ struct check. Mismatches surface at runtime only.
Proposed work
Non-goals
- Compile-time SQL verification (à la
sqlx::query_as!). That requires a live database at compile time and is a much larger lift.
- Breaking the existing hand-written
FromRow API. The derive should be additive — FromRow::from_row(&Row) -> Result<Self> keeps its current signature, the tuple impls at result.rs:758-787 stay as-is, and existing impls (TestUser, Order) compile unchanged.
Performance note
ResultSchema::column_index at result.rs:893 is a linear scan over Vec<ResultColumn>. A naive row.get_by_name::<T>("name") called from from_row would re-scan per field per row — measurably worse for wide rows. The implementation must resolve column names once per query, not per row:
fetch_all_as / fetch_one_as build a name → index lookup table from the Rowset's ResultSchema once, then pass cached indices through to each from_row call.
- The
#[derive(FromRow)] macro emits code that uses those cached indices internally (positional access at the hot path) while presenting a name-based API to the user.
Net: name-based ergonomics, positional speed at the hot path.
Related
A breaking-change alternative covering trait-signature changes (typed RowAccessor, structured Error::Column, removing/feature-gating positional tuple impls) is tracked separately — see the follow-up issue.
Open questions
- Crate layout: separate
hyperdb-api-derive proc-macro crate, or feature-gate inside hyperdb-api?
- Default field-to-column matching: exact name match, or apply a snake_case normalization rule?
Summary
The current
FromRowtrait inhyperdb-apiprovides struct mapping for query results, but every consumer must hand-write a positional impl. This is idiomatic but has rough edges — proposing a#[derive(FromRow)]proc macro and named-column access to address them.Current state
FromRowis defined in hyperdb-api/src/result.rs:744. Typical usage:Reference example: hyperdb-api/tests/remaining_features_tests.rs:244-309.
Pain points
SELECTsilently swaps fields.sqlx'srow.try_get("name")is safer.row.get::<T>(n).ok_or_else(...)..unwrap_or_default(),.unwrap_or(0.0), or error-on-NULL. Easy to be inconsistent;unwrap_or_defaultsilently swallows real NULLs.Proposed work
#[derive(FromRow)]proc macro that generates the impl from struct field names matched to column names by default, with#[hyperdb(rename = "...")]and#[hyperdb(index = N)]overrides for the positional case.Row(e.g.row.get_by_name::<T>("name")) so hand-written impls can opt out of positional brittleness.get_by_name(no trait signature change):Option<T>fields → NULL becomesNone; non-Optionfields → NULL is an error with a clear message naming the column. Document this in the trait rustdoc. Existing hand-writtenFromRowimpls keep their current behavior unchanged.tests/remaining_features_tests.rsandexamples/async_parity_smoke.rsto demonstrate the derive form alongside the manual impl.Non-goals
sqlx::query_as!). That requires a live database at compile time and is a much larger lift.FromRowAPI. The derive should be additive —FromRow::from_row(&Row) -> Result<Self>keeps its current signature, the tuple impls at result.rs:758-787 stay as-is, and existing impls (TestUser,Order) compile unchanged.Performance note
ResultSchema::column_indexat result.rs:893 is a linear scan overVec<ResultColumn>. A naiverow.get_by_name::<T>("name")called fromfrom_rowwould re-scan per field per row — measurably worse for wide rows. The implementation must resolve column names once per query, not per row:fetch_all_as/fetch_one_asbuild a name → index lookup table from theRowset'sResultSchemaonce, then pass cached indices through to eachfrom_rowcall.#[derive(FromRow)]macro emits code that uses those cached indices internally (positional access at the hot path) while presenting a name-based API to the user.Net: name-based ergonomics, positional speed at the hot path.
Related
A breaking-change alternative covering trait-signature changes (typed
RowAccessor, structuredError::Column, removing/feature-gating positional tuple impls) is tracked separately — see the follow-up issue.Open questions
hyperdb-api-deriveproc-macro crate, or feature-gate insidehyperdb-api?