SQL core
sql-core is the shared relational core beneath Cruft's SQLite and PostgreSQL engines. It executes a logical-plan tree (scan, filter, join, sort, aggregate) while the dialect front-end supplies each comparison as a closure. It never sees SQL text, and enforced row and cell caps keep a runaway query from exhausting memory.
Cruft's SQL stack is a shared relational core with two dialect front-ends on top; there is no single engine underneath. The "parameterized vs interpolated" question lands on a different layer than an ORM user expects. This page covers that shared core, sql-core, and what it does and does not decide.
Alpha, unaudited.sql-core0.0.10 ships inside Cruft 0.0.10. It is the dialect-neutral relational plan layer beneathcruft:sqlite(rusty-sqlite) andcruft:postgres(postcrust). Treat every capability here as young and unproven unless a test is cited for it. Do not rely on it against adversarial input in production.
sql-core is a plan executor
sql-core never sees SQL text: it has no parser, no lexer, no tokenizer. Its entire public surface is a logical-plan tree and a row executor. The dialect front-end parses the SQL, resolves the types, and lowers the query into a Plan value, then hands that Plan here to run. This is the crate's defining shape: it is the cleanest piece of the SQL stack precisely because it carries none of the dialect surface.
Concretely, the plan is a single enum (Plan) with these variants: Scan, Values, Filter, Project, Sort, Limit, NestedLoopJoin, HashJoin, Aggregate. Values are a five-arm SqlValue (Null, Int, Real, Text, Blob). Nothing else. The test module exercises filter/project/sort/limit composition, left/right/full null-padding joins, hash-join byte-order parity against the nested-loop plan, and grouping semantics.
The dialect semantics live in the front-end
The core does not know what = means, or how NULL compares, or what LOWER() does. This is the central design choice. Every scalar and predicate crosses the boundary as a Rust closure the front-end supplies: Scalar = Box<dyn Fn(&Row) -> Result<SqlValue, String>> and Pred = Box<dyn Fn(&Row) -> Result<bool, String>>. SQLite's type affinity and Postgres's OID coercion are opposite rulebooks; rather than teach the core both, the core orchestrates operators (bucket these rows, concatenate those, sort by this key) while the front-end owns what any value actually means. The one place the core does compare values, sorting and grouping, it uses a deliberately neutral order (NULL < numbers < text < blob) and lets the front-end pick NULLS FIRST/LAST and collation through SortOptions. Tests confirm both dialect defaults resolve correctly.
So the core neither parameterizes nor interpolates, because it never renders SQL and never touches an untrusted string as text. It receives a Plan whose predicate closure already encapsulates the comparison. The injection question is real, but it is answered one layer up.
Where parameterization is actually decided
The Crizzle ORM and both dialect front-ends were once documented as using bound parameters ("a parameter, never interpolated"). That claim was false as written. The mechanism is escaped-literal interpolation, not driver-level parameter binding:
- Postcrust builds a
$n-placeholder string plus a params vector, thensubstitute_paramsre-interpolates those params back into the SQL text viarender_literal(quote-doubling plus type casts) before the engine parses it. - The rusty-sqlite path interpolates directly with
sql_literaland carries no params vector at all.
Injection safety is real in observed behavior (a '; DROP TABLE users; -- value is stored verbatim and executes nothing, covered by tests), but it rests entirely on the escaper being complete, not on the value ever being a separate bound argument. The source and tests describe the actual mechanism as separated values plus escaped typed-literal substitution, with quote, backslash, statement-breakout, and Unicode-quote payload cases plus compile-checked fuzz targets for both dialects. The correction was truth-in-documentation plus test coverage, not a switch to real parameter binding. Cruft's SQL injection defense is an escaper proven against payloads, not driver parameterization, and none of it lives in sql-core.
Do not conflate this with Crizzle's HALT/PROPAGATE/SANITIZE boundary, which validates data flowing from the database into JS (result soundness, schema drift). It is not an input-to-SQL boundary, and SANITIZE is not an input sanitizer. Injection safety comes solely from the escaping path above.
The core's own safety property is memory
sql-core has its own hazard, unrelated to injection. Because the executor materializes each operator stage (a Scan consumes fully materialized rows; there is no pull iterator and no spill-to-disk), a syntactically valid cartesian join or a huge intermediate result could grow unbounded in memory and OOM the process.
That is bounded and enforced. Plan::execute runs under a default ExecutionLimits of 1,000,000 rows / 16,000,000 cells, and execute_with_limits lets a front-end or harness choose tighter caps. Every result-growing operator (scan/values, filter, project, sort-key materialization, limit, nested-loop join, hash join, aggregate) charges its row and cell budget with checked arithmetic before growing a vector (check_push_cells). Tests cover oversized scans, cartesian-join blowup, and projected-cell blowup. Spill-to-disk and pull iterators remain performance refinements, explicitly not safety gaps.
Two more notes on memory safety: the crate has no unsafe, and its execute path returns Result<_, String> rather than panicking on bad input; the range-index probe short-circuits an inverted BETWEEN range that would otherwise panic BTreeMap::range. The EqIndex / EqIndexN access paths that let a front-end answer WHERE col = ? in O(log n) are tested for scan-order-stable probe results.
Limitations
sql-coregives you correct-by-test relational operators and enforced memory caps. It does not give you a SQL parser, a type system, an expression evaluator, a query optimizer (the optimizer is thin today), or a storage engine.Scanis handed already-materialized rows; the front-end and the storage tier do that work.- It does not stream. Every stage materializes. The row/cell caps prevent an OOM, but a large legitimate query is bounded by turning it into an error, not by spilling to disk.
- It carries no injection defense, because it renders no SQL. If you are auditing Cruft's injection posture,
sql-coreis the wrong file. Readpostcrust'srender_literal/substitute_paramsandrusty-sqlite'ssql_literal; that escaper is the security-critical code, and it is an escaper, not a parameter binder. - Against a mature crates.io ORM plus a battle-tested driver (which do bind parameters at the protocol level and stream large result sets), Cruft's stack wins on nothing here except being accurately labeled. The maturity baseline still has years of adversarial hardening
sql-coreand its front-ends have not seen. - The operator tests are unit tests over plans built directly in the tests. There is no external SQL conformance corpus at this layer (there cannot be: it parses no SQL), and no fuzzing of the executor itself.