moonsqls

    SQL Server for moondb — the TDS protocol, spoken directly in pure MoonBit, with TLS through moontls.

    sqlserver
    mssql
    tds
    sql
    driver
    database
    moondb
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    10 hours ago
    Downloads
    14

    #moonsqls

    SQL Server for moondb — TDS 7.4 spoken directly in pure MoonBit, with TLS through moontls and no client library anywhere.

    moon add moonbitstack/moonsqls

    async fn run() -> Unit raise {
    let db = @moonsqls.SqlServerConn::connect(
    "127.0.0.1", 1433, "sa", "your-password", "master",
    )
    db.execute("CREATE TABLE hero (id INT PRIMARY KEY, name NVARCHAR(64))", []) |> ignore
    db.execute("INSERT INTO hero VALUES (?, ?)", [@moondb.Int(1), @moondb.Text("Nova")])
    |> ignore
    let rows = db.query("SELECT name FROM hero WHERE id = ?", [@moondb.Int(1)])
    println(rows[0].text_by("name")) // Nova
    db.close()
    }

    #What it speaks

    prelogin → encryption negotiated in the same packet as the login ↓ TLS handshake carried inside prelogin packets login7 → credentials, the database, and the 7.4 extension block, ↓ or TDS 8.0 with TLS already under the whole session batch/RPC → one TDS packet per message, wrapped in TLS once it is on ↓ tokens → COLMETADATA, ROW, NBCROW, DONE, ERROR, ENVCHANGE, RETURNVALUE

    Three details are what make TDS awkward, and all three are in this driver:

    • Encryption is negotiated before the login, and the handshake runs inside prelogin packets. Afterwards the arrangement flips: whole TDS packets — headers included — are the plaintext inside TLS. A driver that encrypts only payloads connects, gets a completed handshake, and then has the server close the socket without a message.
    • The transaction descriptor has to be carried. BEGIN TRANSACTION hands out eight bytes in an ENVCHANGE, and every later request must repeat them; the server answers error 3989 otherwise.
    • Parameters travel through sp_executesql, by name. ? becomes @p1, the values go as named RPC parameters beside the statement, and the statement's own two parameters are @statement and @params.
    • A message may not be sealed into one TLS record. The server reads a record at a time, so two TDS packets in one record leave it waiting for the rest of the first and it closes the connection without a message. Each packet is sealed and sent on its own.

    #Prepared statements

    prepare compiles the text with sp_prepare and every run after that is an sp_execute carrying only the handle and the values — the statement text does not travel again. close releases the handle with sp_unprepare.

    sp_prepare is told the type of every parameter, and the types come from the values, so a statement with placeholders compiles at its first bind and one without compiles at prepare, where a syntax error belongs. A bind whose values declare different types drops the stale handle and compiles again rather than sending values the plan did not expect. capabilities().prepared_statements is true.

    #Bulk copy

    let copied = conn.bulk_copy("hero", ["id", "name"], [
    [@moondb.Int(1), @moondb.Text("Nova")],
    [@moondb.Int(2), @moondb.Text("Ivan")],
    ])

    The table is armed with INSERT BULK, which declares the shape of the stream, and then one message carries the column metadata and every row. Column types are inferred from the first value in each column and declared wider than any destination column — bigint for an integer, nvarchar(max) for a string, varbinary(max) for bytes — so a later row cannot overflow a width an earlier one chose; the server converts into the real column types. A NULL travels as the type's own null, and a value of any size travels as a PLP run.

    #TDS 8.0

    SqlServerConn::connect(..., strict=true) starts TLS before the first TDS byte, so prelogin and the login inside it are encrypted whatever the server would have offered, and announces itself as version 8.0 over the ALPN protocol tds/8.0. strict_tls() is the configuration that carries the protocol name; a caller who passes their own tls for a strict connection has to name it themselves. A server older than SQL Server 2022 fails the handshake, which is the point of asking for it.

    #Type mapping

    SQL ServerValue
    BITBool
    TINYINT, SMALLINT, INTInt
    BIGINTInt64
    REAL, FLOATDouble
    NVARCHAR, NCHARText
    VARCHAR, CHARText
    VARBINARY, BINARYBlob
    UNIQUEIDENTIFIERUuid
    DECIMAL, NUMERICNumeric (digits converted exactly)
    DATE, TIME, DATETIME2, DATETIME, SMALLDATETIMETime
    NULLNull

    Anything else — XML, GEOGRAPHY, HIERARCHYID, user-defined types — raises NotSupported rather than reading back as something it is not.

    #What the session sets for itself

    connect runs SET ANSI_NULLS ON; SET ANSI_NULL_DFLT_ON ON; SET QUOTED_IDENTIFIER ON. The second is not decoration: without it the server classes the client as DB-Library and a column declared without NULL/NOT NULL comes out NOT NULL, which turns an ordinary migration into a surprise.

    #Tested against a real server

    CI starts mcr.microsoft.com/mssql/server:2022-latest and runs the shared conformance matrix through this driver: connect, bind and read back every Value case, prepare and re-run, a cursor, commit and rollback, a savepoint, and the classification of a duplicate key and a malformed statement — 12 of 12, no skips. The savepoint case uses T-SQL's spelling (SAVE TRANSACTION / ROLLBACK TRANSACTION), which is a dialect difference the matrix carries rather than a capability the server lacks.

    Alongside it, nine offline tests pin the bytes: the prelogin option table, the login's length field and password obfuscation, the extension block a 7.4 login must have, the 8.0 version field a strict login carries, the ?-rewriting that skips literals and comments, the token stream's metadata/row/error shapes, the RETURNVALUE a prepared handle arrives in, and the metadata and cell layout of a bulk stream. And four live tests cover what only a server can answer: the handle sp_prepare assigns, bulk copy of 201 rows across six types, a parameter of twenty kilobytes, and a response of two thousand rows.

    #Not covered

    • MARS (multiple active result sets) — the option is declined in prelogin, so one statement at a time per connection.
    • XML columns and spatial types. They raise NotSupported rather than reading back as something they are not.
    • DECIMAL in a bulk stream. A bulk column is written as one of the types the driver infers from the values; a decimal destination converts from the nvarchar(max) an exact numeric travels as, which is correct but not the narrowest form.
    • Integrated (Windows) authentication. SQL authentication only.

    #The TLS dependency is local, for now

    moontls is included as a workspace member at vendor/moontls, a snapshot of that repository's main branch, so this repository builds against the TLS code it was tested with rather than whichever version happens to be on the registry. Once moontls publishes the revision this driver needs, delete vendor/moontls, drop the member from moon.work, and the pinned moonbitstack/moontls@… import in moon.mod takes over again.

    #License

    Apache-2.0 © Leo Cheng.

    SqlServerConn

    pub struct SqlServerConn {
    wire : TdsWire
    closed : Bool
    database : String
    txn : Bytes
    }

    A live connection to a SQL Server instance, speaking TDS 7.4 directly. Open it with [SqlServerConn::connect]; it implements @moondb.AsyncDriver, so a query layer written for PostgreSQL or MySQL drives it unchanged.

    Every statement is either a SQL batch or an RPC to sp_executesql, and the answer is read out of the token stream — COLMETADATA, ROW, NBCROW, DONE, ERROR — rather than through a client library.

    SqlServerConn::begin

    SqlServerConn::bulk_copy

    async fn SqlServerConn::bulk_copy(self : SqlServerConn, table : String, columns : Array[String], rows : Array[Array[
    Value
    ]]) -> Int64 raise
    DbError

    Copy rows into table, one cell per name in columns, and report how many rows the server took.

    The rows travel in the protocol's bulk-load format: INSERT BULK declares the shape, then one message carries the column metadata and every row. Values are bound into that stream, never spliced into the statement, so a string containing a quote is a string.

    let copied = conn.bulk_copy("hero", ["id", "name"], [
    [@moondb.Int(1), @moondb.Text("Nova")],
    [@moondb.Int(2), @moondb.Text("Ivan")],
    ])

    SqlServerConn::capabilities

    SqlServerConn::close

    async fn SqlServerConn::close(self : SqlServerConn) -> Unit

    SqlServerConn::commit

    async fn SqlServerConn::commit(self : SqlServerConn) -> Unit raise
    DbError

    SqlServerConn::connect

    async fn SqlServerConn::connect(host : String, port : Int, user : String, password : String, database : String, encrypt? : Bool, strict? : Bool, tls? :
    Client
    ) -> SqlServerConn raise
    DbError

    Open a connection. encrypt defaults to true: the login packet carries a password, and prelogin is where the client says so. A server that answers "encryption not supported" gets a clear session, because the alternative is refusing to connect to it.

    strict selects TDS 8.0, where TLS is not negotiated at all: it has already started before the first TDS byte, so prelogin and the login inside it are encrypted whether the server would have offered encryption or not. A server older than SQL Server 2022 fails the handshake — which is the point of asking for it.

    SqlServerConn::database

    fn SqlServerConn::database(self : SqlServerConn) -> String

    The database the session is in.

    SqlServerConn::dialect

    SqlServerConn::ping

    async fn SqlServerConn::ping(self : SqlServerConn) -> Bool

    SqlServerConn::prepare

    SqlServerConn::query_stream

    SqlServerConn::rollback

    async fn SqlServerConn::rollback(self : SqlServerConn) -> Unit raise
    DbError

    SqlServerStmt

    pub struct SqlServerStmt {
    conn : SqlServerConn
    sql : String
    params : Array[
    Value
    ]
    handle : Int
    declaration : String
    closed : Bool
    }

    One SQL text compiled by the server, run with new values on every call.

    The handle comes from sp_prepare and is released by sp_unprepare; between the two, a run is a sp_execute RPC that sends the handle and the values and nothing else — the statement text does not travel again.

    A statement's declaration is part of what the server compiles: sp_prepare is told the type of every parameter, and those types come from the values. So a handle is compiled at the first bind that has values (or immediately, for a statement with no placeholders), and a later bind whose values declare different types drops the old handle and compiles a new one rather than sending values the plan did not expect.

    SqlServerStmt::bind

    SqlServerStmt::bind_named

    async fn SqlServerStmt::bind_named(self : SqlServerStmt, args : Map[String,
    Value
    ]) -> Unit raise
    DbError

    SqlServerStmt::close

    async fn SqlServerStmt::close(self : SqlServerStmt) -> Unit

    SqlServerStmt::new

    fn SqlServerStmt::new(conn : SqlServerConn, sql : String) -> SqlServerStmt

    SqlServerStmt::params

    fn SqlServerStmt::params(_self : SqlServerStmt) -> Array[String]

    SqlServerStmt::sql

    fn SqlServerStmt::sql(self : SqlServerStmt) -> String

    TdsWire

    pub struct TdsWire {
    sock :
    Tcp

    tls :
    Conn
    ?
    pending : Bytes
    packet_id : Int
    }

    The transport under the protocol.

    permissive_tls

    A TLS configuration that encrypts without authenticating: SQL Server ships a self-signed certificate, and a driver that refused it would refuse every default install. Pass your own @tls.Client to connect to check the chain instead.

    strict_tls

    The configuration a TDS 8.0 session needs: the same permissive trust as [permissive_tls], plus the ALPN protocol tds/8.0. The server uses ALPN to tell which protocol version a TLS connection that started before any TDS byte is carrying; without it the handshake is refused, so a caller who supplies their own configuration for a strict connection has to name the protocol themselves.