Sign in

    kingbase-client

    KingbaseES client for MoonBit: PostgreSQL wire protocol 3.0, SCRAM-SHA-256, text COPY, and a dialect layer for the pg / oracle / mysql / sqlserver compatibility modes

    kingbase
    database
    client
    wire-protocol
    scram
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    10 hours ago
    Downloads
    2

    #shiyukonghui/kingbase-client

    A KingbaseES client for MoonBit: PostgreSQL wire protocol 3.0, SCRAM-SHA-256 and plaintext authentication, COPY FROM STDIN for bulk loading, and a dialect layer for the four KingbaseES compatibility modes (pg, oracle, mysql, sqlserver).

    Written for, and measured against, KingbaseES V009R001C010 (server_version 12.1).

    #Install

    Path dependency, next to this checkout:

    { "deps": { "shiyukonghui/kingbase-client": { "path": "../kingbase-client" } } }

    From the registry:

    moon add shiyukonghui/kingbase-client

    In a package that uses it — the imports go in a moon.pkg file, because during development the JSON package-config form resolved path aliases wrongly:

    import { "shiyukonghui/kingbase-client" @kb, "shiyukonghui/kingbase-client/dialect" @dialect, }

    #Requirements

    The sys package talks to the socket through a small C file (sys/stub.c), so the client builds for the native target only. moon.mod declares preferred_target and supported_targets as native, so moon test, moon check and moon info need no --target; asking for another backend is one explicit incompatibility error rather than a silent skip. On Windows the Microsoft toolchain environment still has to be set before moon runs, and native.cmd does that and forwards its arguments, so use cmd //c "native.cmd test".

    Written for and re-checked on moon 0.1.20260920 / moonc v0.10.14. The source uses the APIs that compiler recommends — @string.parse_int, @buffer.Buffer(...), Map([]) — so an older one may warn on them or not have them at all, and a newer one can turn a deprecation you ignored into an error.

    TLS is not implemented: the client sends SSLRequest, accepts the server's N answer, and continues in plaintext. That is what this deployment offers.

    #Use

    let cfg = @kb.new_config("db.example.com", 54321, "app", secret, "app")
    let client = @kb.connect(cfg)
    let rs = client.query("select id, amount from mb_orders where id = 42")
    println(rs.cell(0, 1))
    client.close()

    Every value comes back as the server's own text, which is why a mode that prints a timestamp differently cannot break a read. ResultSet.scalar(), first_text(col) and pairs() cover the common shapes.

    Bulk loading writes a batch of rows into a reusable buffer and streams it:

    let stream = client.copy_in("copy mb_orders (id, name, amount) from stdin")
    let row = @kb.new_copy_row(@buffer.Buffer(size_hint=1024))
    row.write_int64(7L)
    row.write_text("product-1")
    row.write_decimal(1234567L, 4) // numeric(18,4) -> 123.4567
    row.end_row()
    stream.send_data(row.take())
    println(stream.finish()) // "COPY 1"

    CopyRow::start_field hands over the underlying buffer for a value that is cheaper to write as bytes than to build as a String.

    DML reads the row count out of the command tag, and opens a transaction with the spelling the connected mode takes:

    let d = client.dialect()
    let n = client.query("update mb_orders set status = 'closed' where id <= 1000").affected()
    println(n) // 1000 -- "UPDATE 1000"; `SELECT 1` and `COPY 5000` parse the same way
    client.execute(d.begin_statement()) // "begin", or "begin transaction" in sqlserver mode
    client.execute("commit")

    affected() returns 0 for a command that reports no rows, such as SHOW or SET.

    #The four modes

    database_mode is an internal setting chosen at initdb (initdb -m sqlserver), so one instance has one mode and it cannot be switched per session. The client reads it, together with the settings that refine it, during connect, and stores the result as a Dialect:

    let d = client.dialect()
    println(d.mode) // Sqlserver
    println(d.setting("sql_mode")) // ONLY_FULL_GROUP_BY,ANSI_QUOTES
    println(d.column_type(@dialect.Timestamp)) // datetime
    println(d.limit_clause(100, 500)) // offset 500 rows fetch next 100 rows only

    new_config_fixed_mode skips that read when the mode is already known.

    Two rules shape the layer:

    1. Behaviour is read from settings, not assumed from the mode name. enable_ci, ora_input_emptystr_isnull, quoted_identifier, sql_mode, copy_mode, DateStyle, DateFormat and standard_conforming_strings each decide a rule the mode name alone would only guess at. Not every mode defines every name, so the client reads the catalog view first, then show for each name still missing, and treats a refusal as "this mode has no such setting" rather than as a failure.
    2. Every rule carries its evidence in a comment: measured (observed on a live instance) or documented (manual only). All four modes below have been measured against one instance each on 2026-10-09.

    Rulepgoraclemysqlsqlserver
    integer typeintegernumber(10)intint
    big integerbigintnumber(19)bigintbigint
    date-and-time typetimestamptimestampdatetimedatetime
    boolean column typebooleannumber(1)booleanbit
    boolean text, read back after a COPY round tript / f1 / 0t / f1 / 0
    '' stored in a varcharnot NULLNULLnot NULLnot NULL
    pagination the dialect emitslimit n offset moffset m rows fetch next n rows onlylimit n offset moffset m rows fetch next n rows only
    also accepted thereoffset/fetchtop, limit m,n, rownumtop, limit m,n, rownumtop, limit m,n
    identifier quoting"x""x""x" and `x` "x" and [x]
    string concatenation\|\|\|\|concat()+ (\|\| refused under ANSI_QUOTES)
    catalog viewspg_* onlysys_* and pg_*sys_* and pg_*sys_* and pg_*
    opens a transactionbeginbeginbeginbegin transaction only
    return type of sum(integer)bigintnumericbigintint
    smalldatetimeabsentabsentabsentpresent
    varchar2 / numberabsentpresentpresentabsent

    Every column in that table is a rule the dialect encodes, and sqlserver mode's sum row is a correctness trap: sum(int) there returns int, so a total over more than about 2 billion silently wraps instead of raising. The same 1M rows aggregate to 1335346292 in sqlserver mode and 9925280884 in the other three — the difference is exactly 2^33. Dialect::wide_sum casts where it must, which is why benchmark checksums are comparable between modes.

    #Measured limits, in all four modes

    • The extended query protocol is refused everywhere: a lone Parse message answers 08P01 ("insufficient data left in message"), from this library and from an independent implementation alike. The session survives it. The simple protocol is what the client uses.
    • COPY ... FROM STDIN WITH (FORMAT BINARY) works in all four modes: a PGCOPY image loads and reads back correctly. WITH (BINARY) is not a valid option and answers 42601. Dialect::binary_copy_supported reports it.
    • Prepared statements are reachable as SQL. A parameterised one (prepare p(int) as select $1 + 1; execute p(41)) runs in every mode. A parameterless one runs in pg, oracle and mysql mode, but sqlserver mode reads execute p as a stored-procedure call (42883) and execute p() as a syntax error (42601).
    • getdate() — the SQL Server form — is refused in all four modes; now() answers everywhere and is what Dialect::now_expression returns.
    • sqlserver mode refuses bare begin (42601, "syntax error at end of input"), because there the word opens a BEGIN ... END block. begintransaction opens one in all four modes, and begin tran — SQL Server's abbreviation — only in that one. commit and rollback are bare-safe everywhere, so only the opening statement needs a dialect: Dialect::begin_statement.
    • In sqlserver mode a column declared timestamp is the rowversion type, not a time: cast('2022-01-01 10:20:30' as timestamp) yields 0x323032322D30312D. Use datetime, which the dialect does.
    • Text output differs per mode and per function: pg mode prints a UTC offset for both now() and current_timestamp, sqlserver mode prints one for now() but none for current_timestamp, mysql mode prints none for either. The client therefore returns values as text and parses nothing.

    #Validating an instance

    The companion benchmark repository (KingBase-Test) ships three commands, all taking the usual connection options:

    kingbase_bench probe --host=IP --port=PORT --user=NAME --password=SECRET --database=NAME # 52 read-only checks kingbase_bench livecheck --host=IP ... # 14 dialect rules, one verdict each kingbase_bench crud --host=IP ... # 37 single-row and batch DML checks

    livecheck and crud have been run against one instance per mode. The first reports all 14 dialect rules hold in pg, oracle, mysql and sqlserver mode; the second reports 37 CRUD checks passed in each. crud is the DML half of the library — INSERT, UPDATE, DELETE, ResultSet::affected, boolean and empty-string literals, and transactions — which is also where the begin refusal in sqlserver mode was found. Both write only session temp tables, which the server drops when the connection closes. The probe output and the two verdict tables are archived in that repository under docs/data/probe-<mode>.txt, docs/data/mode-matrix.txt and docs/data/crud-matrix.txt.

    #Tests

    native.cmd test runs the offline suite (30 tests): mode mapping, per-mode type names, pagination and concatenation, identifier quoting, NULL and boolean text, catalog view names, integer-sum widening, transaction spellings, COPY field escaping, command-tag row counts, and the SCRAM vectors. No test opens a socket, so the suite passes without a server.

    #Release

    CHANGELOG.md records what each version contains; moon.mod carries the version, and it must be higher than any version already on the registry.

    moon.mod sets preferred_target and supported_targets to native, which is what makes moon package and moon publish work at all: without it moon checks the default wasm-gc target, and sys/stub.c bindings do not compile there. The declared target set also tells a consumer, in machine-readable form, that this module is native-only.

    Check on the toolchain the registry uses, not only on the one you wrote the code against. The registry compiles the uploaded archive with its own current compiler, and that is a harder test than a local moon check: 0.1.0 passed locally on moonc v0.8.3 and failed to build on the server, because the newer core has removed strconv's integer parser and turned a batch of deprecated constructs into warnings that a fresh consumer sees immediately. moon info, moon check and moon test should all be clean on the current release before moon publish.

    Before a release, in this order:

    cmd //c "native.cmd check --target native" # 0 errors, 0 warnings cmd //c "native.cmd test --target native" # the offline suite cmd //c "native.cmd info --target native" # regenerate pkg.generated.mbti moon package --list # what would be uploaded

    moon package --list runs the check and prints the archive contents; sys/stub.c has to be in that list, because a consumer cannot build the client without it. _build/ is never uploaded, and neither is a dotfile.

    Publishing needs a registry account, and the module name already carries it: a mooncakes module name must begin with the publisher's username, which is why this module is shiyukonghui/kingbase-client.

    moon register # once, at https://mooncakes.io moon login # writes ~/.moon/credentials.json moon publish # version must be SemVer and higher than any published one

    There is no unpublish or delete command, so a published version stays public. The current CLI has moon deprecate --reason ..., which marks every published version of a module, and moon deprecate --undo clears that again; neither removes anything. Bump version in moon.mod and add a CHANGELOG.md entry in the same commit, then tag it.

    Client

    pub struct Client {
    sock :
    Socket

    cfg : Config
    params : Map[String, String]
    backend_pid : Int
    transaction_status : Byte
    dialect :
    Dialect

    }

    Client::close

    fn Client::close(self : Client) -> Unit

    Sends the terminate message and closes the socket.

    Client::copy_in

    fn Client::copy_in(self : Client, sql : String) -> CopyStream raise

    Client::dialect

    The dialect of the connected instance.

    Client::execute

    fn Client::execute(self : Client, sql : String) -> String raise

    Runs a statement, discards its rows and returns the command tag.

    Client::extended_try

    fn Client::extended_try(self : Client, sql : String) -> Result[String, ServerError] raise

    This is a capability probe. The client itself runs statements through the simple protocol, because that path needs one message per query and works on every mode measured here.

    Client::mode

    The compatibility mode of the connected instance.

    Client::pid

    fn Client::pid(self : Client) -> Int

    The process id from BackendKeyData.

    Client::query

    fn Client::query(self : Client, sql : String) -> ResultSet raise

    The simple protocol is the only form this client sends, because the target rejects Parse and Bind with SQLSTATE 08P01. A mode that takes the extended protocol reports it through Dialect::extended_protocol_supported.

    Client::setting

    fn Client::setting(self : Client, key : String) -> String

    A ParameterStatus value the backend sent, e.g. server_version.

    Client::status_byte

    fn Client::status_byte(self : Client) -> Byte

    I when the backend is idle, T inside a transaction, E after a failure.

    Client::try_execute

    fn Client::try_execute(self : Client, sql : String) -> Result[String, ServerError]

    Runs a statement and returns its command tag, or the server error.

    Client::try_query

    fn Client::try_query(self : Client, sql : String) -> Result[ResultSet, ServerError]

    A capability probe needs a result for every attempt, so one refusal must not end the run. query raises the describe text of a ServerError, which keeps the SQLSTATE in the leading brackets, so read it back from there.

    Config

    pub struct Config {
    host : String
    port : Int
    user : String
    password : String
    database : String
    detect_dialect : Bool
    fallback_mode :
    Mode
    ?
    }

    CopyRow

    pub struct CopyRow {
    buf :
    Buffer

    fields : Int
    }

    The row writes into a buffer the caller owns and reuses, so a loader builds no String per field. Each write_* method adds the field separator itself, so a caller cannot leave a row with the wrong field count.

    CopyRow::end_row

    fn CopyRow::end_row(self : CopyRow) -> Unit

    Ends the row. A newline is what separates rows in text COPY.

    CopyRow::len

    fn CopyRow::len(self : CopyRow) -> Int

    Bytes written so far, which is how a loader decides that a batch is full.

    CopyRow::start_field

    Use this for a value that is cheaper to write as bytes than to build as a String, e.g. a timestamp written digit by digit. At 10M rows the encoder, not the server, is the limit, so no row may cost an allocation per field.

    CopyRow::take

    fn CopyRow::take(self : CopyRow) -> Bytes

    The buffer keeps its capacity, so a loader that sends batch after batch allocates once, and the payload still goes out as two writes: the 5-byte header and these bytes.

    CopyRow::write_bool

    fn CopyRow::write_bool(self : CopyRow, d :
    Dialect
    , value : Bool) -> Unit

    Writes a boolean as the text this mode expects.

    CopyRow::write_decimal

    fn CopyRow::write_decimal(self : CopyRow, v : Int64, scale : Int) -> Unit

    Writes v / 10^scale with exactly scale fraction digits.

    CopyRow::write_int

    fn CopyRow::write_int(self : CopyRow, v : Int) -> Unit

    Writes a decimal count with no fraction digits.

    CopyRow::write_int64

    fn CopyRow::write_int64(self : CopyRow, v : Int64) -> Unit

    Writes a signed 64-bit count.

    CopyRow::write_null

    fn CopyRow::write_null(self : CopyRow) -> Unit

    Writes the NULL marker.

    CopyRow::write_opt_text

    fn CopyRow::write_opt_text(self : CopyRow, value : String?) -> Unit

    Writes a text field, or NULL when there is no value.

    CopyRow::write_text

    fn CopyRow::write_text(self : CopyRow, value : String) -> Unit

    Writes a text field with the text-COPY escapes applied.

    CopyStream

    pub struct CopyStream {
    client : Client
    }

    takes 1 and 0 as field text, and pg mode takes t and f.

    CopyStream::finish

    fn CopyStream::finish(self : CopyStream) -> String raise

    Ends the stream and returns the command tag, e.g. "COPY 10000000".

    CopyStream::send_data

    fn CopyStream::send_data(self : CopyStream, payload : Bytes) -> Unit raise

    them with a header.

    ResultSet

    pub struct ResultSet {
    columns : Array[String]
    rows : Array[Array[String?]]
    command : String
    }

    Every value is the server's own text output. The client parses no type, so a mode that prints a timestamp differently cannot break a read.

    ResultSet::affected

    fn ResultSet::affected(self : ResultSet) -> Int

    The number is the server's own, so a DML check does not have to read the table back to know what changed — and INSERT reports 0 n, which is why the scan takes the trailing digits and not the first ones.

    ResultSet::cell

    fn ResultSet::cell(self : ResultSet, row : Int, col : Int) -> String

    ResultSet::column_count

    fn ResultSet::column_count(self : ResultSet) -> Int

    ResultSet::first_text

    fn ResultSet::first_text(self : ResultSet, col : Int) -> String

    ResultSet::pairs

    fn ResultSet::pairs(self : ResultSet) -> Array[(String, String)]

    Rows as (first column, second column) pairs, for setting and catalog reads.

    ResultSet::row_count

    fn ResultSet::row_count(self : ResultSet) -> Int

    ResultSet::scalar

    fn ResultSet::scalar(self : ResultSet) -> String

    The first column of the first row, or "" when the set is empty.

    ServerError

    pub(all) struct ServerError {
    severity : String
    code : String
    message : String
    }

    ServerError::describe

    fn ServerError::describe(self : ServerError) -> String

    buffer_text

    fn buffer_text(buf :
    Buffer
    ) -> String

    Buffer::to_string reinterprets bytes as code units on the native target, so report and log text must decode explicitly.

    connect

    fn connect(cfg : Config) -> Client raise

    downgrade would be invisible to the caller.

    escape_copy_field

    fn escape_copy_field(buf :
    Buffer
    , value : String) -> Unit

    Escapes one text-COPY field.

    mode_of

    The GUC also reports 0 to 3, so both spellings are accepted.

    new_config

    fn new_config(host : String, port : Int, user : String, password : String, database : String) -> Config

    A configuration that detects the dialect of the target.

    new_config_fixed_mode

    fn new_config_fixed_mode(host : String, port : Int, user : String, password : String, database : String, mode :
    Mode
    ) -> Config

    A configuration that skips the detection query, for a connection used once by a program that already knows the mode.

    new_copy_row

    A row that writes into buf.

    now_us

    fn now_us() -> Int64

    This is the only clock a caller should use for timings: it does not move when the wall clock is corrected.

    sleep_ms

    fn sleep_ms(ms : Int) -> Unit

    Waits for ms milliseconds, e.g. between COPY rounds in a paced load.

    write_copy_null

    fn write_copy_null(buf :
    Buffer
    ) -> Unit

    The NULL marker used by text COPY.