neyuki 0.6.3

A small Lua-like scripting language implemented in Rust
local table = require("@neyuki/table")

local sql = {}

-- The drivers are native (src/vm/libs/sql.rs, on the `postgres` and `mysql`
-- crates); this module validates arguments and builds the `Connection`
-- object. Statements take positional parameters in the server's own
-- syntax, `$1, $2` on PostgreSQL and `?` on MySQL, and a params array
-- supplies them, so values never get spliced into SQL text. A nil entry
-- binds as NULL.
--
-- Rows come back as { column = value } tables: integers stay integers,
-- floats floats, booleans booleans (PostgreSQL) and text strings; NULL is
-- nil. Decimals, dates, times, timestamps, uuids and json arrive as strings
-- in the server's text form so nothing is rounded.

function checkString(s: string, name: string)
    if (s == nil) then error(name .. " must be provided") end
    if (type(s) != "string") then error(name .. " must be a string") end
end

function checkOptionalTable(t: {any}?, name: string)
    if (t != nil and type(t) != "table") then error(name .. " must be a table") end
end

-- Opens a connection. `target` is a URL such as
-- "postgres://user:pass@host:5432/db" or "mysql://user:pass@host/db", or a
-- table with `driver` ("postgres" or "mysql") plus `host`, `port`, `user`,
-- `password`, `database`, `ssl` (true to require TLS, false to refuse it;
-- unset, TLS is required for any host but this machine unless a PostgreSQL
-- URL sets `sslmode`) and `timeout` (seconds to wait for the connection). A
-- table may carry a `url` too, with the other fields overriding parts of it.
function sql.connect(target: string | ConnectOptions): Connection
    local options
    if (type(target) == "string") then
        options = { url = target }
    elseif (type(target) == "table") then
        options = target
    else
        error("target must be a URL string or an options table")
    end

    local id, driver = __sql_open(options)
    local closed = false
    local connection = {}
    connection.driver = driver

    function checkOpen()
        if (closed) then error("connection is closed") end
    end

    -- Runs a statement and returns its rows as an array of { column = value }
    -- tables; `rows.columns` lists the column names in select order. Columns
    -- that are NULL are absent from their row.
    function connection.query(self: Connection, statement: string, params: {any}?): {Row}
        checkOpen()
        checkString(statement, "statement")
        checkOptionalTable(params, "params")
        return __sql_query(id, statement, params)
    end

    -- The first row `query` would return, or nil when there is none.
    function connection.queryOne(self: Connection, statement: string, params: {any}?): Row?
        local rows = self:query(statement, params)
        return rows[1]
    end

    -- Runs a statement for its effect and returns the number of rows it
    -- touched, followed by the id MySQL assigned to an auto-increment
    -- insert (nil on PostgreSQL, where `RETURNING id` with `query` does the
    -- same job).
    function connection.execute(self: Connection, statement: string, params: {any}?): (number, number?)
        checkOpen()
        checkString(statement, "statement")
        checkOptionalTable(params, "params")
        return __sql_execute(id, statement, params)
    end

    function connection.begin(self: Connection): ()
        self:execute("BEGIN")
    end

    function connection.commit(self: Connection): ()
        self:execute("COMMIT")
    end

    function connection.rollback(self: Connection): ()
        self:execute("ROLLBACK")
    end

    -- Runs `body(connection)` inside a transaction: committed once `body`
    -- returns, rolled back if it raises, in which case the error is raised
    -- again here. Returns what `body` returned.
    function connection.transaction(self: Connection, body: (Connection) -> any): any
        if (type(body) != "function") then error("body must be a function") end
        self:begin()
        local ok, result = try(body, self)
        if (ok) then
            self:commit()
            return result
        end
        -- The rollback is best effort: if the connection is gone, the
        -- original error is the one worth reporting.
        try(self.rollback, self)
        error(result)
    end

    -- Whether the server still answers on this connection.
    function connection.ping(self: Connection): boolean
        checkOpen()
        return __sql_ping(id)
    end

    function connection.close(self: Connection): ()
        if (closed) then return end
        closed = true
        __sql_close(id)
    end

    function connection.isOpen(self: Connection): boolean
        return not closed
    end

    return table.freeze(connection)
end

return table.freeze(sql)