Experiment 117: Named parameters

Date: 2026-05-01

Status: Rejected (deferred for v0.x launch — see Decision)

Direction:parameter-encoding-and-binding

Implementation:exp-117-named-parameters branch, PR #83 (closed unmerged)

Problem

resqlite only accepts positional ? parameters. SQLite supports four placeholder

syntaxes natively (:name, @name, $name, ?NNN), all resolved through

sqlite3_bind_parameter_index. Peers (sqlite3, sqlite_async, drift) accept

named maps; the resqlite ergonomics gap shows up in any code that constructs SQL

with a templating layer or a struct-shaped row.

The constraint is that the existing positional hot path has been tuned across

many experiments (009 batch FFI, 028 SQLITE_STATIC, 070 zero-row buffer, 077

cached bind_parameter_count, 109 inline param buffer, 113 direct batch matrix).

A naïve "switch on every call" implementation would regress all of them. The

acceptance criterion was therefore "named ergonomics at zero cost to positional".

Hypothesis

Add Map<String, Object?> as a second accepted shape on every public

parameter-taking method (select, selectBytes, execute, executeBatch,

stream, transaction.execute, transaction.select,

transaction.executeBatch). The Dart-side encoder writes a different layout

for named binds; the C-side dispatcher picks between two binders based on the

sign of param_count (negative = named).

The positional bind loop in C is left structurally identical: the dispatcher

inlines into a single sign check that's loop-invariant in run_batch_locked,

so the optimizer should hoist it out of the per-row hot path. The single-row

positional encoder in Dart (allocateParams(List<Object?>)) is unchanged.

Approach

Public API

Database.select, selectBytes, execute, stream, executeBatch, plus the

matching Transaction methods, accept Object for parameters. At runtime the

caller-provided value must be either:

sigil exactly as written in the SQL

For executeBatch, every row in paramSets must have the same shape (all

positional or all named). Mixed batches throw ArgumentError on the main

isolate via assertUniformParamSets.

Wire format

Two parameter struct shapes share the same [structs][bytes] reusable native

buffer (introduced in exp 109):

 positional (24 bytes / slot, unchanged): type:i32  pad  int_val:i64 OR float_val:f64 OR { ptr:i64; len:i32; pad }   (text or blob) named (32 bytes / slot, new): type:i32  name_len:i32  name_ptr:i64 union (16 bytes, mirrored layout): int_val:i64 float_val:f64 { data_ptr:i64; len:i32; pad } (text or blob) 

Dart → C calls pass param_count as a signed int. Positive = positional;

negative absolute value = named entry count. The buffer pointer is reinterpreted

on the C side based on sign.

C dispatch

 static inline int bind_params_dispatch(stmt, params, param_count, expected) { if (__builtin_expect(param_count >= 0, 1)) { return bind_params(stmt, params, param_count, expected);  // existing } return bind_params_named(stmt, params, -param_count, expected);  // new } 

bind_params is byte-identical to before. bind_params_named copies each

entry's name onto a 64-byte stack buffer (or heap for unusually long names),

calls sqlite3_bind_parameter_index, and binds via the same sqlite3_bind_*

family. Unknown / missing names return SQLITE_RANGE.

Results

Functional: 26 new named-parameter tests pass; full existing suite (127

tests) continues to pass. All four placeholder syntaxes work, in select,

execute, executeBatch, transaction, and stream, with mixed types

including unicode names and zero-length blobs.

Performance (2-repeat release suite, candidate vs baseline):

BenchmarkBaseline msCandidate msDelta
Single Inserts (100 sequential)2.182.12-3%
Batch Insert (1000 rows)0.400.42+5%
Batch Insert (10000 rows)3.964.14+5%
Wide Batch Insert (10000 × 20 params)16.2620.76+28%
Batched Write Inside Transaction (1000 rows)0.660.75+14%
Nested Transactions (savepoints) batch1.051.26+20%

The Wide Batch +28% was scrutinized with a focused 5-run A/B on the same shape:

Baseline p50 (ms)Candidate p50 (ms)
Median18.1018.60
Range16.84..19.6218.33..19.41

Focused 5-run delta: +2.7% with overlapping ranges.

Suite-level: 16 wins, 2 regressions, 143 neutral.

Decision

Deferred for v0.x launch. Will reopen post-launch if either condition holds.

The implementation is functionally complete and correct. The 5-run focused

harness shows the wide-batch +28% in the 2-repeat suite is largely small-sample

noise — the real delta is +2.7%, within overlapping ranges. So this is not

clearly broken.

What it is, on close reading, is a 12K-LOC change touching the entire write

path (writer isolate, reader pool, FFI bindings, native dispatch) where:

  1. The trend across write-path metrics is consistently positive (slower).

6 of 7 measured write benchmarks went the wrong direction. Each individually

is "within noise," but collectively the direction is monotonic — that's

weaker evidence than independent random fluctuation would suggest.

  1. The wire format changes the Dart↔C contract. Once shipped, future

native-side changes have to maintain dual-mode dispatch in perpetuity.

  1. The ergonomics benefit is real but modest.{':id': 42} vs [42] is

convenient for SQL with many parameters but doesn't change what users can

do. Peers offer it, and resqlite's lack of it is a documented gap, but it

is not the reason anyone picks or rejects this library.

For a pre-launch package whose primary marketing claim is performance, shipping

an invasive hot-path change for an ergonomics nice-to-have is a worse trade

than shipping without it and adding it later if real users ask for it.

Reopen if

shows neutral wide-batch numbers, and community feedback indicates named

parameters are an actual barrier to adoption (not just a "would be nice"

comment).

run_batch_locked (positional vs named) instead of routing every per-row

bind through the inline dispatch helper. The agent's hypothesis was that the

wide-batch drift came from the optimizer failing to hoist the dispatch

branch out of the inner loop in release builds; specializing the loop would

eliminate that risk entirely. If that variant benchmarks clean on a 5-run,

the cost/benefit shifts and merge is the right call.

The full implementation lives on the

exp-117-named-parameters branch

and can be cherry-picked, rebased, or used as a starting point for the

specialized-loop variant.

Future Notes

If this is reopened, two follow-ups already noted by the implementation pass

are worth profiling:

  1. Cache name → bind_index in the statement cache entry. Today every bind

calls sqlite3_bind_parameter_index which walks the statement's parameter

list. For a stmt rebound many times with the same map shape, a per-entry

(name → idx) cache would replace the strcmp walk with a hash lookup.

  1. Shrink the named struct from 32 to 24 bytes by storing the name as a

tail-block offset rather than a 64-bit pointer — matching the positional

layout — at the cost of one extra address-decode in bind_params_named.

Skip unless wire format width shows up as a memory constraint.

If a real use case wants to pass the same map to many back-to-back queries

(e.g. batched update where id=:id with stable keys but varying values),

pre-encoding the buffer once and re-binding values into the existing layout

could save a lot of utf8.encode work on the names.