Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"
members = [
"hyperdb-api-core",
"hyperdb-api",
"hyperdb-api-derive",
"hyperdb-api-node",
"hyperdb-api-salesforce",
"hyperdb-mcp",
Expand Down
130 changes: 130 additions & 0 deletions MIGRATING-0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,136 @@ The MCP server's `Engine::execute_in_transaction` helper takes `&self` and so ca

---

## #61 + #62 — FromRow modernization

The `FromRow` trait was redesigned around a new [`RowAccessor`] type and a new [`#[derive(FromRow)]`][derive] proc-macro. The blanket tuple impls (1/2/3/4-tuple) were deleted; hand-written impls have a new signature.

[`RowAccessor`]: https://docs.rs/hyperdb-api/latest/hyperdb_api/struct.RowAccessor.html
[derive]: https://docs.rs/hyperdb-api/latest/hyperdb_api/derive.FromRow.html

### What's changed

| Surface | Before (v0.2.x) | After (v0.3.0) |
| ------- | --------------- | -------------- |
| `FromRow::from_row` signature | `fn from_row(row: &Row) -> Result<Self>` | `fn from_row(row: RowAccessor<'_>) -> Result<Self>` |
| Blanket tuple impls | `(Option<A>,)` … `(Option<A>, Option<B>, Option<C>, Option<D>)` | **Deleted.** Define a struct with `#[derive(FromRow)]` instead. |
| Derive macro | did not exist | `#[derive(FromRow)]` from the new `hyperdb-api-derive` crate (re-exported by `hyperdb-api`) |
| Name-based access on a single row | did not exist | `Row::get_by_name<T>(name)` |
| Cached column-name → index lookup | did not exist | `RowAccessor` carries one; built once per query in `fetch_*_as` |

### What's new

- **`#[derive(FromRow)]`** generates the `impl FromRow` for you. Field names match column names by default; `#[hyperdb(rename = "...")]` overrides the column name; `#[hyperdb(index = N)]` switches to positional access at column `N`. `Option<T>` fields use `get_opt` / `position_opt` (NULL → `None`); other fields use `get` / `position` (NULL → error). `rename` and `index` are mutually exclusive.

```rust
use hyperdb_api::FromRow;

#[derive(FromRow)]
struct User {
id: i32,
name: String,
#[hyperdb(rename = "email_address")]
email: Option<String>,
}

// Useful for queries with computed/unnamed columns, e.g.
// `SELECT id, COUNT(*) FROM ... GROUP BY id`.
#[derive(FromRow)]
struct Aggregate {
#[hyperdb(index = 0)]
id: i32,
#[hyperdb(index = 1)]
total: Option<i64>,
}
```

- **`RowAccessor<'a>`** is the parameter type of the new `FromRow::from_row`. It exposes:
- `get<T>(name: &str) -> Result<T>` — required field; missing/NULL/type-mismatch return `Error::Column`.
- `get_opt<T>(name: &str) -> Result<Option<T>>` — optional field; NULL becomes `None`.
- `position<T>(idx: usize) -> Result<T>` — positional access; out-of-range returns `Error::ColumnIndexOutOfBounds`.
- `position_opt<T>(idx: usize) -> Result<Option<T>>` — positional access; NULL becomes `None`.

- **`Row::get_by_name<T>(name)`** does the same name-based lookup but on a single `Row` (no cached lookup map). Convenient for hand-coded paths that don't go through `FromRow`. Doc warns that it's a linear scan; recommends `#[derive(FromRow)]` or `fetch_*_as` for hot paths.

### Migration recipes

#### Hand-written `FromRow` impl

```rust
// Before
impl FromRow for User {
fn from_row(row: &Row) -> Result<Self> {
Ok(User {
id: row.get::<i32>(0).ok_or_else(|| Error::conversion("NULL id"))?,
name: row.get::<String>(1).unwrap_or_default(),
})
}
}

// After
impl FromRow for User {
fn from_row(row: RowAccessor<'_>) -> Result<Self> {
Ok(User {
id: row.get("id")?,
name: row.get_opt("name")?.unwrap_or_default(),
})
}
}
```

The new shape is shorter, more readable, and decouples your code from column position — reordering `SELECT` columns no longer breaks your impl.

#### Tuple destructuring (deleted)

```rust
// Before — blanket tuple impl
let row = conn.fetch_one("SELECT id, name FROM users")?;
let (id, name): (Option<i32>, Option<String>) = FromRow::from_row(&row)?;

// After — define a struct
#[derive(FromRow)]
struct User { id: Option<i32>, name: Option<String> }
let user: User = conn.fetch_one_as("SELECT id, name FROM users")?;
```

Or, if you really want positional access without a struct, use `Row::get(idx)` directly:

```rust
let row = conn.fetch_one("SELECT id, name FROM users")?;
let id: Option<i32> = row.get(0);
let name: Option<String> = row.get(1);
```

#### Direct `T::from_row(&row)` calls

If you previously called `T::from_row(&row)` directly (outside `fetch_*_as`), the new signature requires a `RowAccessor`. Easiest migration: use `fetch_one_as` / `fetch_all_as` instead, which build the cached lookup for you.

If you must construct a `RowAccessor` yourself (e.g. processing rows from a custom source), the constructor is `pub(crate)`. File an issue if you need this surfaced — current direction is to keep `RowAccessor` construction internal so the cache lifetime stays tied to `fetch_*_as`.

### Errors

The derive and `RowAccessor` accessors return `Error::Column { name, kind }` for column-access failures, where `ColumnErrorKind` is one of:

- `Missing` — column with that name not in the result schema
- `Null` — required field, but the cell is SQL `NULL`
- `TypeMismatch { expected, actual }` — the cell value couldn't be decoded as `T`

`Error::ColumnIndexOutOfBounds { idx, column_count }` is returned by `RowAccessor::position` when `idx` is out of range.

These variants were shipped in `#70` so this PR doesn't re-break the error type.

### Performance note

`fetch_*_as` builds a `HashMap<&str, usize>` once per query (O(N) in the column count). Each row's `RowAccessor::get(name)` then runs a single hash lookup followed by typed access — O(1) per field per row. This is strictly better than the previous behavior, where a hand-written impl using `try_get(idx, name)` had to know column positions hard-coded.

For one-off named access on a `Row` outside `fetch_*_as`, `Row::get_by_name` is a linear scan over `ResultSchema::column_index`. For hot paths (many rows × many fields), prefer `#[derive(FromRow)]`.

### `hyperdb-api-derive` crate

The proc-macro lives in a new `hyperdb-api-derive` workspace crate (Rust requires proc-macro code to live in its own `proc-macro = true` crate). It's re-exported from `hyperdb-api`, so callers don't need a direct dependency — same pattern as serde / thiserror. **Don't add `hyperdb-api-derive` to your `Cargo.toml`**; just `use hyperdb_api::FromRow;`.

---

## #70 (continued) — Ergonomic constructors across all workspace error types

The same ergonomic-constructor pattern was applied to every error type in the workspace that user code might construct, so call sites no longer need `.to_string()` ceremony for string-literal arguments.
Expand Down
23 changes: 23 additions & 0 deletions hyperdb-api-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "hyperdb-api-derive"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "Procedural macros for hyperdb-api (FromRow derive)"
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
keywords = ["database", "hyper", "derive", "proc-macro"]
categories = ["database"]

[lib]
proc-macro = true

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"

[lints]
workspace = true
132 changes: 132 additions & 0 deletions hyperdb-api-derive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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.

This crate provides the procedural macros that `hyperdb-api` re-exports
(currently just `#[derive(FromRow)]`). Use them through `hyperdb-api`.

## Quick example

```rust
use hyperdb_api::{Connection, ConnectionBuilder, FromRow, Result};

#[derive(Debug, FromRow)]
struct User {
id: i32,
name: String,
#[hyperdb(rename = "email_address")]
email: Option<String>,
}

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<User> = conn.fetch_all_as("SELECT * FROM users ORDER BY id")?;
for u in &everyone {
println!("{u:?}");
}
Ok(())
}
```

## 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<T>` 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.

```rust
#[derive(FromRow)]
struct Aggregate {
#[hyperdb(index = 0)]
id: i32,
#[hyperdb(index = 1)]
total: Option<i64>,
}
// Works against `SELECT id, COUNT(*) FROM ... GROUP BY id`
```

## When to hand-write `FromRow` instead

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:

```rust
impl FromRow for User {
fn from_row(row: hyperdb_api::RowAccessor<'_>) -> Result<Self> {
Ok(User {
id: row.get("id")?,
name: row.get("full_name")?, // SQL column "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**.

| | Required (`T`) | Optional (`Option<T>`) |
|---|---|---|
| **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<T>`.

The Rust field type and the accessor must agree: `position` returns
`Result<T>`, `position_opt` returns `Result<Option<T>>`. Mixing them
across types is a compile error, not a runtime mismatch:

```rust
// ✅ field type matches accessor return
let email: Option<String> = row.position_opt(2)?;
let id: i32 = row.position(0)?;

// ❌ compile errors
let email: Option<String> = row.position(2)?; // returns T, not Option<T>
let id: i32 = row.position_opt(0)?; // returns Option<T>
```

If you want to silently default a NULL on a non-`Option` field, opt in
explicitly:

```rust
name: row.position_opt(1)?.unwrap_or_default(), // NULL → ""
```

See the [`hyperdb-api` docs](https://docs.rs/hyperdb-api) for full usage.

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.
Loading
Loading