sqlkit

package module
v0.0.0-...-591bdfc Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

sqlkit

sqlkit is SQL support that knows nothing about any domain. A store written once against it runs natively on PostgreSQL, MySQL and SQLite, through database/sql, pgx or GORM, and behaves identically on all seven combinations.

It owns what varies by database and nothing that varies by application:

  • the three dialects and the questions a store asks of them;
  • statements, their bind markers and a writer that numbers them;
  • the codecs that make every driver read back the same values;
  • schema rendering, a development runner and verification against an expectation the store supplies;
  • the Executor contract, with one executor per driver.

The tables and statements belong to the store built on it. sqlkit never names one.

Install

Require the executor for the driver you already use; it brings the toolkit with it.

go get github.com/kartaladev/sqlkit/stdsql@v0.1.0
# or github.com/kartaladev/sqlkit/pgx@v0.1.0, github.com/kartaladev/sqlkit/gorm@v0.1.0

Modules

Module Package What it holds
github.com/kartaladev/sqlkit sqlkit dialects, Statement and Writer, Rows, codecs, schema rendering and runner, verification, Executor, errors; standard library only
github.com/kartaladev/sqlkit/stdsql stdsqlexec Executor over *sql.DB
github.com/kartaladev/sqlkit/pgx pgxexec Executor over *pgxpool.Pool
github.com/kartaladev/sqlkit/gorm gormexec Executor over *gorm.DB
github.com/kartaladev/sqlkit/sqlkittest sqlkittest the executor conformance suite, a fixture schema per dialect, and container helpers

sqlkittest is its own module so that sqlkit never pulls in testcontainers.

Each module is versioned and tagged on its own, with its directory as the tag prefix:

Module Tag
github.com/kartaladev/sqlkit v0.1.0
github.com/kartaladev/sqlkit/sqlkittest sqlkittest/v0.1.0
github.com/kartaladev/sqlkit/stdsql stdsql/v0.1.0
github.com/kartaladev/sqlkit/pgx pgx/v0.1.0
github.com/kartaladev/sqlkit/gorm gorm/v0.1.0

A module is tagged only after every sibling it requires: the root first, then sqlkittest, then the three executors.

Executors

Pick the module for the driver you already use.

db, _ := sql.Open("pgx", dsn)
executor, err := stdsqlexec.New(db, sqlkit.PostgreSQL)
executor, err := pgxexec.New(pool) // always PostgreSQL
executor, err := gormexec.New(gormDB, sqlkit.MySQL)

A nil handle or dialect is a configuration error from New, matching sqlkit.ErrConfiguration. Nothing connects at construction.

Build every statement with the executor's own dialect:

w := sqlkit.NewWriter(executor.Dialect())
w.Write("SELECT id FROM widgets WHERE owner = ", w.Bind(owner))

err := executor.Query(ctx, w.Done(), func(rows sqlkit.Rows) error {
    for rows.Next() {
        // scan into any, then decode with sqlkit.DecodeString and friends
    }
    return nil
})

Default: a store uses one of the three executors. Override: any type satisfying sqlkit.Executor, sqlkit.Execer and sqlkit.Querier works with every sqlkit-based store. Prove it with sqlkittest.RunExecutorSuite.

Transactions

Executor.Do runs a function in a transaction, with the same rules on every driver:

  • Whoever begins, commits. When the context already carries a transaction for this executor, Do joins it and neither commits nor rolls back.
  • Nested scopes join and flatten, never a savepoint. An inner failure aborts the whole scope once the error reaches the outermost Do. The GORM executor never calls db.Transaction, whose savepoints would break this.
  • An error, a panic or a cancelled context rolls back. A panic is re-raised unchanged.

A caller that began a transaction itself hands it over with the executor package's ContextWithTx, and reads it back with TxFromContext.

Limit, stated: each executor keeps its transaction under its own context key, separate from any other library's. A transaction another library carries on the context is not visible to a sqlkit executor. A host that wants one transaction across both reads it from that library's context and passes it on explicitly:

if tx, ok := sqlstore.TxFromContext(ctx); ok {
    ctx = stdsqlexec.ContextWithTx(ctx, tx)
}

Queries take a callback

Executor.Query hands rows to a function and releases them afterwards, checking their error, whether the function succeeded or not. A store cannot leak a connection by forgetting rows.Close(), which returning rows would allow on every driver.

Limit, stated: a query cannot hand its rows out past the callback. Collect what you need inside it.

Values

Every value a store writes reads back the same on every combination:

  • Instants are UTC, truncated to the microsecond (sqlkit.NormalizeTime). EncodeTime writes a native timestamp where the dialect has one, and the fixed-width sqlkit.TimestampLayout text where it does not, so ordering by the column is chronological on SQLite too.
  • JSON payloads are text columns, never jsonb or MySQL JSON, which reorder keys and rewrite numbers. EncodeRaw and DecodeJSON keep them byte for byte.
  • Identifiers compare case-sensitively, because every dialect pins an identifier collation (C, utf8mb4_0900_as_cs, BINARY). A store's schema applies it to its identifier columns.

Schemas

A store publishes one schema document per dialect, carrying sqlkit.PrefixToken wherever a table prefix goes.

  • RenderSchema(document, prefix) applies a prefix and splits the document into statements.
  • ApplySchema runs them in order, stopping at the first failure and naming it.
  • DropTables removes tables in reverse order, cascading on PostgreSQL.
  • VerifySchema(ctx, querier, dialect, prefix, expectation) compares the live schema against a SchemaExpectation and reports every missing table, missing column, wrong identifier collation and missing index in one *SchemaError, which matches sqlkit.ErrSchemaMismatch and sqlkit.ErrConfiguration.

Limit, stated: ApplySchema and DropTables are for tests and development. There is no migration tool: no versioning, no down direction and no locking. Production applies the published schema through its own pipeline, and runs VerifySchema at startup.

Limit, stated: verification checks tables, columns, identifier collations and indexes by name. It does not check column types.

Testing a store or an executor

sqlkittest.RunTestPostgres, RunTestMySQL and RunTestSQLite provision a database and return a DSN, which every driver can use. Images are pinned.

sqlkittest.RunExecutorSuite(t, factory) runs the conformance suite against an executor: statements and row release, transactions and nesting, rollback on error, panic and cancellation, value round trips, and schema verification on a live database. One database serves the whole suite, with a fresh table prefix per case.

Development

make lint test          # lint (including the dependency allowlist) and tests
make executor-matrix    # the conformance suite on all seven combinations; needs Docker

The five modules resolve each other through go.work.

Origin

sqlkit was built inside hmntsk, where it was extracted from the task engine's SQL layer (hmntsk's openspec/changes/archive/2026-09-14-sqlkit). This repository was seeded from hmntsk's sqlkit/ directory at commit 32e7763 (last sqlkit change 95f9278), as a plain copy: its history starts here.

License

sqlkit is licensed under the Apache License, Version 2.0.

Documentation

Overview

Package sqlkit is dialect-portable SQL support that knows nothing about any domain.

It owns what varies by database and nothing that varies by application: the three dialects, statements and their bind markers, the text and time codecs that make PostgreSQL, MySQL and SQLite read back the same values, schema rendering, a development migration runner, schema verification against an expectation the caller supplies, and the Executor contract that the driver modules implement for database/sql, pgx and GORM.

A store written once against Executor runs natively on all three drivers. The stores built on sqlkit decide their own tables and statements; sqlkit never names one.

sqlkit depends on the standard library only. Its executor modules add the driver or ORM they adapt, and its conformance-suite module adds its test libraries; no library built on sqlkit is ever a dependency of any of them.

Index

Constants

View Source
const PrefixToken = "{{PREFIX}}"

PrefixToken is what a published schema document carries wherever the host's table prefix goes: in table names, index names and foreign-key references alike.

View Source
const TimestampLayout = "2006-01-02T15:04:05.000000Z"

TimestampLayout is the one encoding written into a dialect with no native timestamp type.

It is fixed-width, UTC, and carries exactly six fractional digits, so that lexical ordering equals chronological ordering and a value read back compares equal to the value written — which is the whole reason for pinning it rather than letting each driver choose.

Variables

View Source
var ErrConfiguration = errors.New("sqlkit: invalid configuration")

ErrConfiguration matches every wiring mistake sqlkit reports: a nil handle or dialect given to an executor, and a live schema a store cannot run against. Both are found before traffic, at construction or at startup.

View Source
var ErrSchemaMismatch = fmt.Errorf("%w: the live schema does not match what is expected", ErrConfiguration)

ErrSchemaMismatch matches a *SchemaError. It also matches ErrConfiguration, because a schema that does not meet its expectation is a wiring problem, discovered at startup.

Functions

func ApplySchema

func ApplySchema(ctx context.Context, execer Execer, dialect Dialect, statements []string) error

ApplySchema runs schema statements in order, stopping at the first failure and naming the statement that failed.

It exists for tests and for development, where a conformance suite has to build a schema on three engines on every run. It is not a migration tool: it has no versioning, no down direction and no locking, and a production deployment applies the published schema through its own pipeline instead.

func DecodeInt

func DecodeInt(value any) (int64, error)

DecodeInt reads back an integer column, mapping NULL to zero. Drivers differ in whether they hand back an int64, a float64 or the decimal text of the value, so all of them are accepted.

func DecodeJSON

func DecodeJSON(value any) (json.RawMessage, error)

DecodeJSON reads back a JSON column as raw bytes, whatever shape the driver returned it in, byte for byte. NULL and empty text are no payload.

func DecodeString

func DecodeString(value any) (string, error)

DecodeString reads back a text column, mapping NULL to the empty string.

func DecodeTime

func DecodeTime(value any) (time.Time, error)

DecodeTime reads back whatever a driver produced for a timestamp column: a time.Time from a native column, or text from a dialect without one. NULL and empty text are the zero time.

func DropTables

func DropTables(ctx context.Context, execer Execer, dialect Dialect, tables []string) error

DropTables removes tables in reverse of the order given, so that a table is dropped after everything created later that may reference it. On PostgreSQL each drop cascades.

Like ApplySchema, it exists for tests, so that one database can serve many cases, and is not part of any published migration path.

func EncodeRaw

func EncodeRaw(payload []byte) any

EncodeRaw renders an opaque JSON payload for storage. It is passed as text rather than bytes because a driver that sees a byte slice may reasonably decide the column is binary. No payload becomes NULL.

func EncodeTime

func EncodeTime(dialect Dialect, instant *time.Time) any

EncodeTime renders an instant as a dialect stores it: a normalised time.Time where the dialect has a native timestamp type, and TimestampLayout text where it does not. A nil or zero instant becomes NULL.

func NormalizeTime

func NormalizeTime(instant time.Time) time.Time

NormalizeTime rounds an instant to the precision and zone every dialect can store exactly: UTC, truncated to whole microseconds.

func RenderSchema

func RenderSchema(document, prefix string) []string

RenderSchema substitutes a table prefix into a schema document and splits it into executable statements, in document order. An empty prefix leaves the names bare and no token behind.

func SplitStatements

func SplitStatements(document string) []string

SplitStatements breaks a schema document into executable statements, dropping comments and blank lines.

A statement ends at a semicolon on the end of a line, which a published schema is written to respect; that keeps this honest without a SQL parser. A trailing statement with no semicolon is kept.

func TrimSQL

func TrimSQL(sql string) string

TrimSQL collapses a statement's whitespace, for readable test failures and log lines.

func VerifySchema

func VerifySchema(
	ctx context.Context, querier Querier, dialect Dialect, prefix string, expectation SchemaExpectation,
) error

VerifySchema compares the live schema against an expectation, with a table prefix applied to every table and index name, and reports every discrepancy in one *SchemaError rather than failing on first use.

It checks that every table and column exists, that identifier columns carry the collation that makes comparison case-sensitive, and that every expected index exists, by name. A missing table is reported once, without its columns or indexes. It does not check column types: a dialect has several spellings for the same storage, and a type mismatch that matters shows up as a failing statement immediately, while a wrong collation shows up months later as the wrong match.

A failure to read the schema at all is returned as itself, not as issues.

Types

type ConfigurationError

type ConfigurationError struct {
	// Detail says what is wrong and, where there is one, what to pass instead.
	Detail string
}

ConfigurationError reports a contradictory or meaningless configuration, such as a nil database handle. It matches ErrConfiguration.

func (*ConfigurationError) Error

func (e *ConfigurationError) Error() string

Error implements the error interface.

func (*ConfigurationError) Unwrap

func (e *ConfigurationError) Unwrap() error

Unwrap makes the error match ErrConfiguration.

type Dialect

type Dialect interface {
	// Name identifies the dialect in errors and in schema file names.
	Name() string
	// Placeholder renders the bind marker for the one-based position of an
	// argument: $1 on PostgreSQL, ? on the others.
	Placeholder(position int) string
	// Quote renders an identifier so that a column named "type" or "from" is
	// not a syntax error.
	Quote(identifier string) string
	// SupportsReturning reports whether the dialect can return rows from a
	// write. MySQL cannot, so a portable store must not rely on it.
	SupportsReturning() bool
	// SupportsSkipLocked reports whether SELECT ... FOR UPDATE SKIP LOCKED is
	// available. SQLite has no row-level locking at all, so a portable store
	// claims rows with a conditional update instead and treats this only as an
	// optimisation it may offer.
	SupportsSkipLocked() bool
	// IdentifierCollation is the collation that identifier columns must carry
	// for comparison to be case-sensitive and ordering to be byte-wise. It is
	// never empty: every dialect pins one.
	IdentifierCollation() string
	// TimestampColumnType is the column type for a UTC instant stored at
	// microsecond precision.
	TimestampColumnType() string
	// JSONColumnType is the column type for an opaque JSON payload.
	//
	// It is a text type on every dialect, never a native JSON type. PostgreSQL
	// jsonb and MySQL JSON both normalise what they are given — they reorder
	// object keys, drop insignificant whitespace and rewrite number literals —
	// and a store that promises to give a payload back exactly as it was handed
	// over cannot use them.
	JSONColumnType() string
	// BooleanTrue renders the literal the dialect stores for true, used where a
	// boolean must appear inline in generated DDL.
	BooleanTrue() string
	// UpsertSuffix renders the clause that turns an INSERT into an insert-or-
	// update on conflict with keyColumns, refreshing updateColumns. The three
	// dialects spell this three different ways and one of them does not name
	// the key at all.
	UpsertSuffix(keyColumns, updateColumns []string) string
}

Dialect is everything about a database that changes the SQL.

It deliberately exposes capabilities as questions rather than letting statement builders test the dialect's name: a builder that asks SupportsSkipLocked works for a dialect added later, and one that asks whether it is talking to PostgreSQL does not.

var (
	// PostgreSQL is the PostgreSQL dialect, 9.5 or later.
	PostgreSQL Dialect = postgres{}
	// MySQL is the MySQL dialect, 8.0 or later.
	MySQL Dialect = mysql{}
	// SQLite is the SQLite dialect, 3.35 or later.
	SQLite Dialect = sqlite{}
)

The three supported dialects. They are values, not constructors, because a dialect holds no state.

func DialectByName

func DialectByName(name string) (Dialect, bool)

DialectByName returns the dialect registered under name, and false when there is none. Names are lower-case: "postgres", "mysql", "sqlite".

func Dialects

func Dialects() []Dialect

Dialects returns the supported dialects, in a stable order.

type Execer

type Execer interface {
	// ExecStatement runs a statement that returns no rows.
	ExecStatement(ctx context.Context, sql string, args ...any) error
}

Execer is the little of a connection the development schema runner needs.

type Executor

type Executor interface {
	// Dialect is the dialect statements for this executor must be built with,
	// bind marker included: the GORM executor reports "?" on every database,
	// because GORM rewrites it for the dialector itself.
	Dialect() Dialect
	// Exec runs a write and returns the number of rows it matched. The zero
	// Statement is a no-op returning 0.
	Exec(ctx context.Context, statement Statement) (int64, error)
	// Query runs a read and hands its rows to scan. The executor releases the
	// rows and checks their error after scan returns, whether scan succeeded or
	// not, so a store can never leak a connection by forgetting to. The zero
	// Statement is a no-op that never calls scan.
	Query(ctx context.Context, statement Statement, scan func(rows Rows) error) error
	// Do runs fn in a transaction.
	//
	// When ctx already carries a transaction for this executor, fn joins it
	// and Do neither commits nor rolls back: whoever begins, commits. Nested
	// scopes flatten into that one transaction, never a savepoint, so a failure
	// anywhere aborts the whole scope once the error reaches the outermost Do.
	// Otherwise Do begins a transaction, commits it when fn returns nil and the
	// context is still live, and rolls it back on an error, a panic (re-raised
	// unchanged) or a cancelled context.
	Do(ctx context.Context, fn func(ctx context.Context) error) error
	// InTransaction reports whether ctx carries a transaction for this
	// executor.
	InTransaction(ctx context.Context) bool
}

Executor runs statements for a store written once against sqlkit, on whichever driver the host uses.

The stdsqlexec, pgxexec and gormexec modules implement it, and one conformance suite in sqlkittest proves they behave identically. Any other type satisfying it works with every sqlkit-based store.

Every executor also implements Execer and Querier, so the schema runner and verification can drive it.

type Querier

type Querier interface {
	// QueryStatement runs a statement and returns its rows. The caller reads
	// them to the end, which releases them on every supported driver.
	QueryStatement(ctx context.Context, sql string, args ...any) (Rows, error)
}

Querier is the little of a connection schema verification needs.

type Rows

type Rows interface {
	// Next advances to the next row.
	Next() bool
	// Scan reads the current row into dest.
	Scan(dest ...any) error
	// Err reports any error that ended the iteration.
	Err() error
}

Rows is the little a scanner needs of a driver's result set. database/sql, pgx and GORM all satisfy it.

type SchemaError

type SchemaError struct {
	// Dialect is the dialect verified against.
	Dialect string
	// Issues is every discrepancy found: missing tables and columns and wrong
	// collations first, in table order, then missing indexes.
	Issues []SchemaIssue
}

SchemaError reports every discrepancy found, not only the first.

Startup is the one moment when the whole schema can be inspected at once and the whole list acted on; discovering the same problems one at a time, as traffic happens to reach each statement, is strictly worse.

func (*SchemaError) Error

func (e *SchemaError) Error() string

Error implements the error interface.

func (*SchemaError) IssueList

func (e *SchemaError) IssueList() string

IssueList renders every issue, semicolon separated, for an error message that wraps this one in its own words.

func (*SchemaError) Unwrap

func (e *SchemaError) Unwrap() error

Unwrap makes the error match ErrSchemaMismatch, and through it ErrConfiguration.

type SchemaExpectation

type SchemaExpectation map[string]TableExpectation

SchemaExpectation is what a store's statements require of the live schema, by unprefixed table name.

type SchemaIssue

type SchemaIssue struct {
	// Table is the table the issue concerns, prefix applied.
	Table string
	// Column is the column, empty when the table or one of its indexes is the
	// problem.
	Column string
	// Detail says what is wrong.
	Detail string
}

SchemaIssue is one discrepancy between the live schema and an expectation.

func (SchemaIssue) String

func (i SchemaIssue) String() string

String implements fmt.Stringer.

type Statement

type Statement struct {
	// SQL is the statement text, with the dialect's bind markers already
	// applied.
	SQL string
	// Args are the bind values, in bind-marker order.
	Args []any
}

Statement is one SQL statement and its bind values, ready for an Executor.

func IndexQuery

func IndexQuery(dialect Dialect, tables []string) Statement

IndexQuery renders the index introspection a dialect offers for the given tables, prefixed names as stored: one row per index, as (table, index).

func SchemaQuery

func SchemaQuery(dialect Dialect, tables []string) Statement

SchemaQuery renders the column introspection a dialect offers for the given tables, prefixed names as stored: one row per column, as (table, column, collation).

func (Statement) IsZero

func (s Statement) IsZero() bool

IsZero reports whether the statement has no text. A builder returns the zero statement when there is nothing to do, and Executor.Exec treats it as a no-op.

type TableExpectation

type TableExpectation struct {
	// Columns is every column the statements read or write, in the order
	// issues about them are reported.
	Columns []string
	// IdentifierColumns are the columns whose comparison must be
	// case-sensitive and whose ordering must be byte-wise, and which therefore
	// must carry the dialect's [Dialect.IdentifierCollation].
	IdentifierColumns []string
	// Indexes are the secondary indexes the statements rely on, by unprefixed
	// name. Primary keys are not listed: every dialect names them differently.
	Indexes []string
}

TableExpectation is what a store's statements require of one table.

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer accumulates SQL and its arguments, handing out bind markers in the dialect's style as it goes.

Markers are numbered across every call on one writer, so a statement written in several steps binds $1, $2, $3 on PostgreSQL in the order the values were bound. A Writer is not safe for concurrent use; build one per statement.

func NewWriter

func NewWriter(dialect Dialect) *Writer

NewWriter returns a writer producing a statement for a dialect.

func (*Writer) Bind

func (w *Writer) Bind(value any) string

Bind records a value and returns the bind marker that stands for it.

func (*Writer) BindAll

func (w *Writer) BindAll(values ...any) string

BindAll records several values and returns their bind markers, comma separated, for an IN list or a VALUES row. No values render nothing.

func (*Writer) Done

func (w *Writer) Done() Statement

Done returns the accumulated statement.

func (*Writer) Write

func (w *Writer) Write(parts ...string)

Write appends literal SQL. Identifiers are quoted by the caller, with the dialect's Quote, before they are written.

Directories

Path Synopsis
gorm module
pgx module
sqlkittest module
stdsql module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL