|
| 1 | +# SQLProvider.Fable |
| 2 | + |
| 3 | +A light SQL data-access runtime for F# that compiles to **Rust** through Fable, and |
| 4 | +to .NET through ADO.NET, from one source. |
| 5 | + |
| 6 | +The shared smoke suite in `tests/SQLProvider.Fable.SmokeTests/Smoke.fs` is written |
| 7 | +once against `ISqlConnector` and runs on both backends unchanged — no `#if` in it. |
| 8 | + |
| 9 | +## Status |
| 10 | + |
| 11 | +| Backend | Driver | Verified | |
| 12 | +|---|---|---| |
| 13 | +| .NET | ADO.NET (`Microsoft.Data.Sqlite`) | 14/14 smoke tests pass | |
| 14 | +| Rust | `rusqlite` (bundled SQLite) | 14/14 smoke tests pass | |
| 15 | + |
| 16 | +Not built yet: the schema code generator, the quotation-to-SQL translator, and |
| 17 | +backends other than SQLite. See [Not done yet](#not-done-yet). |
| 18 | + |
| 19 | +## Why not just port SQLProvider's runtime? |
| 20 | + |
| 21 | +Almost none of `System.Data` survives the trip to Rust, and almost none of it |
| 22 | +needs to. Tracing `executeQuery` in SQLProvider's `SqlRuntime.Linq.fs`, one query |
| 23 | +touches roughly twenty ADO members: |
| 24 | + |
| 25 | +| Type | Members used | |
| 26 | +|---|---| |
| 27 | +| `IDbConnection` | `ConnectionString`, `Open`, `Close`, `State`, `Dispose` | |
| 28 | +| `IDbCommand` | `CommandText`, `CommandTimeout`, `Parameters.Add`, `ExecuteReader`, `ExecuteScalar`, `ExecuteNonQuery` | |
| 29 | +| `IDbDataParameter` | `ParameterName`, `Value`, `DbType`, `Direction` | |
| 30 | +| `IDataReader` | `Read`, `FieldCount`, `GetName`, `GetValue` | |
| 31 | +| `ConnectionState`, `DBNull`, `IDbTransaction`, `IsolationLevel` | — | |
| 32 | + |
| 33 | +Everything else in SQLProvider's `ISqlProvider` — `GetTables`, `GetColumns`, |
| 34 | +`GetRelationships`, `GetSprocs`, `CreateTypeMappings`, the `DataTable` and SSDT |
| 35 | +machinery — is schema introspection. That runs on .NET at code-generation time |
| 36 | +and never reaches a Fable target. |
| 37 | + |
| 38 | +But those twenty members should not be ported either, because each collides with |
| 39 | +Rust: |
| 40 | + |
| 41 | +1. **`obj` values.** `reader.GetValue i : obj` becomes `Lrc<dyn Any>` in Rust, so |
| 42 | + every column read is a downcast. Replaced by the `SqlValue` union. |
| 43 | +2. **`DBNull`** has no Rust analogue and shouldn't get one. It's `option`. |
| 44 | +3. **A mutable `.Parameters` collection.** rusqlite and sqlx want parameters |
| 45 | + supplied at execute time, not accumulated on a command object. |
| 46 | +4. **`Open`/`Close`/`State`.** rusqlite opens on construct; sqlx has a pool. |
| 47 | + There is no ADO connection-state machine to model. |
| 48 | +5. **`System.Transactions.TransactionScope`** — SQLProvider's ambient-scope write |
| 49 | + model has no Rust counterpart. Transactions here are explicit. |
| 50 | + |
| 51 | +So the native surface is one interface with five members |
| 52 | +(`src/SQLProvider.Fable.Core/Connector.fs`), and everything else is portable F# |
| 53 | +on top of it. |
| 54 | + |
| 55 | +## Layout |
| 56 | + |
| 57 | +``` |
| 58 | +src/SQLProvider.Fable.Core portable F#: SqlValue, ISqlConnector, Row readers |
| 59 | +src/SQLProvider.Fable.Ado .NET backend over ADO.NET |
| 60 | +src/SQLProvider.Fable.Rust Fable/Rust backend: F# bindings + sqlite_native.rs |
| 61 | +tests/SQLProvider.Fable.SmokeTests the suite, written once, backend-agnostic |
| 62 | +tests/SQLProvider.Fable.Tests.Net runs it under xunit over ADO.NET |
| 63 | +tests/Rust runs it as a Rust binary over rusqlite |
| 64 | +``` |
| 65 | + |
| 66 | +`Core` and `SmokeTests` must stay free of `System.Data`, reflection, |
| 67 | +`System.Linq.Expressions` and async — anything there has to survive Fable's Rust |
| 68 | +backend. |
| 69 | + |
| 70 | +## Running the tests |
| 71 | + |
| 72 | +.NET: |
| 73 | + |
| 74 | +```bash |
| 75 | +dotnet test tests/SQLProvider.Fable.Tests.Net/SQLProvider.Fable.Tests.Net.fsproj |
| 76 | +``` |
| 77 | + |
| 78 | +Rust (needs `rustup` and the `fable` CLI on PATH): |
| 79 | + |
| 80 | +```bash |
| 81 | +pwsh tests/Rust/build.ps1 |
| 82 | +``` |
| 83 | + |
| 84 | +That script compiles F# to Rust, stages `sqlite_native.rs` and `Cargo.toml` into |
| 85 | +`build/rust`, then runs the binary. It exits non-zero if any test fails. |
| 86 | + |
| 87 | +## Design notes |
| 88 | + |
| 89 | +### Result sets are materialised eagerly |
| 90 | + |
| 91 | +Not laziness: a rusqlite `Rows` iterator borrows the `Statement` that produced |
| 92 | +it, so a reader kept alive across F# calls would need a self-referential struct. |
| 93 | +SQLProvider's own `Sql.dataReaderToArray` already reads the whole reader into an |
| 94 | +array before projecting, so nothing is lost. |
| 95 | + |
| 96 | +### Mapping is generated, not reflected |
| 97 | + |
| 98 | +Fable's Rust `FSharpPropertyInfo` carries only a name (see fable-library-rust |
| 99 | +`QuotationTypes.fs`), so `PropertyInfo.PropertyType` is unavailable and a generic |
| 100 | +"convert each column to whatever the record field wants" mapper has nothing to |
| 101 | +dispatch on. Mappers state conversions explicitly instead: |
| 102 | + |
| 103 | +```fsharp |
| 104 | +let ofRow (r: SqlRow) : Customer = |
| 105 | + { CustomerId = Row.int r "CustomerId" |
| 106 | + Name = Row.text r "Name" |
| 107 | + Country = Row.textOpt r "Country" |
| 108 | + Balance = Row.float r "Balance" } |
| 109 | +``` |
| 110 | + |
| 111 | +This is what the schema code generator will emit per table. It is also faster and |
| 112 | +clearer than reflection on .NET. |
| 113 | + |
| 114 | +### The native boundary carries primitives only |
| 115 | + |
| 116 | +`sqlite_native.rs` never sees a Fable-generated `SqlValue`. Values cross as an |
| 117 | +integer tag plus a typed accessor call, so a change in how Fable lays out unions |
| 118 | +cannot silently break the shim. |
| 119 | + |
| 120 | +### Parameters |
| 121 | + |
| 122 | +SQL text uses `@name` on every backend (rusqlite accepts `@`, `:` and `$`; |
| 123 | +Microsoft.Data.Sqlite accepts `@`). `SqlParam.Name` is the bare name; each |
| 124 | +connector adds the marker its driver wants. |
| 125 | + |
| 126 | +## Fable Rust codegen gaps found while building this |
| 127 | + |
| 128 | +Three, all worked around here, all worth filing upstream: |
| 129 | + |
| 130 | +1. **Object expression calling back into its enclosing object.** Generates a |
| 131 | + struct field literally named `_`, a reserved identifier in Rust, and calls the |
| 132 | + enclosing object's method on the object-expression struct. Worked around with |
| 133 | + a concrete class (`RusqliteTransaction`). Matches the cases already commented |
| 134 | + out in Fable's own `MiscTests.fs`. |
| 135 | +2. **`this :> ISomeInterface` inside the type's own interface implementation.** |
| 136 | + `interface_cast!` expands to a plain `as`, which Rust rejects as a |
| 137 | + non-primitive cast. Worked around by moving the logic to free functions. |
| 138 | +3. **Doubly-nested closure over a captured value.** `Array.init n (fun r -> |
| 139 | + Array.init m (fun c -> ...raw...))` clones `raw` into the outer `Fn` closure |
| 140 | + but *moves* it into the inner one, so it does not borrow-check. Worked around |
| 141 | + with explicit loops and a top-level helper. |
| 142 | + |
| 143 | +Also worth knowing: Fable emits the `#[path = "..."] mod ...` declaration for a |
| 144 | +`.rs` pulled in by `importAll`, but does not copy the file — the build script |
| 145 | +stages it. |
| 146 | + |
| 147 | +## Not done yet |
| 148 | + |
| 149 | +- **Schema code generator.** Reads SQLProvider's own offline schema JSON |
| 150 | + (`SchemaCache.Save`, the documented `ContextSchemaPath` format) and emits |
| 151 | + records plus `ofRow` mappers. File-format coupling only: no assembly |
| 152 | + reference to SQLProvider, no internals, no version lockstep. |
| 153 | +- **Quotation-to-SQL translator.** `<@ fun c -> c.Country = "UK" @>` to a WHERE |
| 154 | + clause, walking the `FSharpExpr` AST that Fable's Rust target now supports. |
| 155 | +- **Blob parameters on Rust** are bound but untested; blob *results* are covered |
| 156 | + by the shim, not by a test. |
| 157 | +- **Other backends.** Postgres/MySQL via sqlx or tokio-postgres will need the |
| 158 | + async story worked out — Fable's Rust async is its own machinery and bridging |
| 159 | + it to tokio is not free. rusqlite is synchronous, which is why it came first. |
| 160 | +- **Dates and decimals** currently travel as text/float. A real backend wants |
| 161 | + native cases on `SqlValue`. |
0 commit comments