sqlutil

package
v1.16.1 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultMaxRows = 1000

DefaultMaxRows is the maximum number of rows ScanRows will fetch to prevent OOM.

View Source
const MaxListExpansion = 65535

MaxListExpansion bounds how many placeholders one token may expand into.

The element count comes from message data, which nothing upstream bounds, and each element costs a placeholder in the statement text and an argument in the bind list. PostgreSQL's wire protocol stops at 65535 parameters and SQL Server at 2100, so a list beyond this never reaches a server that would accept it -- but without the cap it is built in this process first, which is where a pathological array does its damage.

Variables

This section is empty.

Functions

func AsSlice added in v1.8.1

func AsSlice(v any) ([]any, bool)

AsSlice coerces v into a slice of any so it can be expanded into a SQL value list. It reports false for anything that is a scalar to a database driver, including []byte and json.RawMessage, which are bytea/blob and JSON column values rather than lists.

func BuildFirstRowQuery

func BuildFirstRowQuery(sourceType, table string) (string, error)

BuildFirstRowQuery returns a dialect-correct query selecting any single row from a table, with no watermark and no ordering.

It is used for schema probes and for the first read of a table that has no cursor yet. Ordering is intentionally omitted: there is no cursor to advance, so "any row" is the intended semantics and a sort would be wasted work on a large table.

func BuildIncrementalQuery

func BuildIncrementalQuery(sourceType, table, idField string) (string, error)

BuildIncrementalQuery returns a dialect-correct query that selects the single next row after a watermark, ordered ascending by idField. The watermark value is bound as parameter 1; callers must pass it as the only query argument.

Use this instead of hand-writing the query in a connector. Watermark polling looks trivial and is not: the row-limiting clause must be applied after the sort, and three of the four dialect families here spell that differently. Every connector that wrote its own got at least the Oracle case wrong.

Both identifiers are validated and quoted, so the result is injection-safe. An unknown sourceType is an error rather than a guess — emitting plausible SQL for the wrong dialect is the failure mode this helper exists to prevent.

func CanonicalDriver

func CanonicalDriver(sourceType string) (string, bool)

CanonicalDriver maps a user-facing source/sink type (e.g. "mssql", "postgres") to the actual database/sql driver name that must be passed to sql.Open. It is the single source of truth shared by connection opening and placeholder generation so the two can never drift apart. The second return value reports whether the type is backed by a generic SQL driver.

IMPORTANT: this mapping MUST stay in sync with how connections are opened (see registry.getOrOpenDB). For example "mssql" is opened with the microsoft/go-mssqldb "sqlserver" driver, which only accepts @pN placeholders.

func ColumnTypeNames added in v1.8.1

func ColumnTypeNames(rows *sql.Rows) []string

ColumnTypeNames reads the driver's own name for each column, in order.

Returns nil when the driver cannot say. Callers treat that as "no JSON columns", which is the behaviour every source had before JSON columns were decoded at all -- a driver that withholds type metadata must not change a pipeline's shape.

func DecodeColumn added in v1.10.0

func DecodeColumn(v any, typeName string) any

DecodeColumn is the per-value rule, decided from the database's own name for the column's type.

This is the entry point for any caller that has the driver's type metadata. DecodeValue remains for the one that does not: the MySQL binlog reader knows only a go-mysql column enum, never a type name.

Deliberately narrow, and for the same reason in both branches: only a column the database itself calls JSON or an array is reshaped. Deciding from content would reshape every string that happens to look like one.

numeric becomes a json.Number, not a float64. The pgx-native path carries it exactly -- measured on PostgreSQL 18.4, a numeric(40,20) holding 1.00000000000000000001 marshals from pgtype.Numeric at full precision -- so converting to float64 would have flattened it to 1 and propagated a loss rather than closed a gap. json.Number keeps every digit and serialises as the bare number the native path produces, which is what makes the two agree.

Non-finite numerics stay text: PostgreSQL accepts NaN, Infinity and -Infinity, and DecodeNumericText refuses all three because json.Number does not validate and one such cell would fail the whole message's marshalling.

func DecodeJSONColumn added in v1.8.1

func DecodeJSONColumn(raw []byte) any

DecodeJSONColumn gives the bytes of a JSON-typed column the value they represent, so a `jsonb`/`json` column reaches transformations and sinks as an object rather than as a string that happens to contain one.

Only call this for columns the database itself types as JSON -- see IsJSONColumnType, or the source's own type metadata. Applied to a text column it would turn the string "123" into the number 123 and "true" into a boolean.

Bytes that do not parse are returned as a string. A column that cannot be decoded still has to reach the sink; dropping it, or failing the message, is how this class of bug cost data in the first place.

func DecodeNumericText added in v1.10.0

func DecodeNumericText(s string) (json.Number, bool)

DecodeNumericText gives an exact decimal column the shape it has on the pgx-native path: a bare JSON number carrying every digit the database sent.

The bool reports whether it could. Text the JSON number grammar does not accept is returned unchanged, and that is not an edge case -- PostgreSQL numeric accepts NaN, Infinity and -Infinity, none of which has a JSON form. json.Number does not validate on construction, so converting one of those would build a value that fails json.Marshal, and a single such cell turns a whole message into a marshalling error rather than a row with one odd column.

Text is also the better answer for those three on the merits: measured on PostgreSQL 18.4, pgtype.Numeric marshals both infinities to 0, so converging on the native shape there would replace a correct string with silent corruption.

func DecodePGArray added in v1.10.0

func DecodePGArray(raw []byte, typeName string) (any, bool)

DecodePGArray turns a PostgreSQL array literal into a list.

Returns ok=false when the type name is not one pgx knows or the literal does not parse, and the caller keeps the text it already had. A malformed value must stay a malformed value: returning nil would turn it into a missing one, which is the bug being fixed, not a smaller version of it.

func DecodePGXValue added in v1.10.0

func DecodePGXValue(v any) any

DecodePGXValue is the per-value rule the native pgx read paths apply.

It subsumes the `[]byte -> string` conversion those call sites already did, so it is a drop-in for that line rather than an addition to it.

func DecodeValue added in v1.8.1

func DecodeValue(v any, isJSON bool) any

DecodeValue is the per-value rule RecordFromValues applies, exposed for the callers that need to keep hold of a single column as they go. Both carriers matter. database/sql drivers hand text over as []byte, but go-mysql has already converted a MYSQL_TYPE_JSON value to a string by the time the binlog handler sees it -- asserting only on []byte left that column a string, and no unit test built from hand-written []byte could notice.

func GetFromMapPath added in v1.8.1

func GetFromMapPath(m map[string]any, path string) any

GetFromMapPath resolves a dotted path in a nested map[string]any.

func IsJSONColumnType added in v1.8.1

func IsJSONColumnType(databaseTypeName string) bool

IsJSONColumnType reports whether a database's own name for a column type means the column holds JSON.

Deliberately narrow. SQLite has no JSON type and SQL Server had none before 2025, so JSON there lives in TEXT/NVARCHAR columns; treating those as JSON would reshape every string that happens to parse, which is a far larger change than the bug being fixed.

func IsNumericColumnType added in v1.10.0

func IsNumericColumnType(databaseTypeName string) bool

IsNumericColumnType reports whether a database's own name for a column type means the column holds an exact decimal.

Deliberately narrow, for the same reason IsJSONColumnType is: only a column the database itself names as a decimal is reshaped. Deciding from content would turn every text column that happens to hold "42" into a number.

FLOAT8, REAL and DOUBLE PRECISION are excluded because every driver here already delivers them as a float64 -- they were never text and have no precision left to preserve. MONEY is excluded because its text carries a currency symbol ("$19.99"), which is not a JSON number at all; it already agrees across both read paths as a string.

func IsPGArrayTypeName added in v1.10.0

func IsPGArrayTypeName(databaseTypeName string) bool

IsPGArrayTypeName reports whether a database's own name for a column type means the column holds a PostgreSQL array.

pgx names an array by prefixing its element type with an underscore, so an int[] column reports "_INT4" and a text[] column "_TEXT". Dimensionality is not in the name: int[] and int[][] both report "_INT4".

A type pgx has no registered name for reports its bare OID instead -- money comes back as "790" -- so a name-keyed rule has to tolerate names that describe nothing.

func ParameterizeTemplate added in v1.8.1

func ParameterizeTemplate(driver, tpl string, data map[string]any) (string, []any)

ParameterizeTemplate replaces all {{ ... }} tokens in the SQL template with driver-specific placeholders and returns the parameterized SQL text and a corresponding args slice. Token content should be either a path like `source.foo` or a quoted literal. Paths are resolved against `data`.

func Placeholder

func Placeholder(driver string, index int) string

Placeholder returns a bound-parameter placeholder suitable for the driver and 1-based index. The driver argument may be either a user-facing type label (e.g. "mssql") or an actual driver name (e.g. "sqlserver"); both are normalized through CanonicalDriver so the placeholder style always matches the driver that will ultimately execute the query.

func QuoteIdent

func QuoteIdent(driver, name string) (string, error)

QuoteIdent validates and quotes an SQL identifier (optionally schema-qualified) according to the target driver. It supports dot-separated identifiers like schema.table. Drivers: pgx/postgres -> "name", mysql/mariadb/sqlite -> `name`, mssql -> [name].

It returns an error for any malformed or unsafe identifier. Callers MUST check the error and never splice the result on failure, otherwise an empty identifier would be interpolated straight into the SQL (e.g. "WHERE = $1").

NOTE: quoting makes identifiers case-sensitive. On engines that fold unquoted identifiers (e.g. PostgreSQL lower-cases them) a column physically named "id" must be referenced as "id", not "ID".

func RecordFromValues added in v1.8.1

func RecordFromValues(names, typeNames []string, values []any) map[string]any

RecordFromValues builds one row map from a scanned row.

Driver []byte becomes a string, as it always has, except where typeNames says the column is JSON or a PostgreSQL array -- there it becomes the value the literal describes, matching what pgx already produces for those columns on its own paths.

typeNames may be nil or shorter than names.

func ScanRows

func ScanRows(rows *sql.Rows) ([]map[string]any, error)

ScanRows scans sql.Rows into a slice of maps. It is hard-limited to DefaultMaxRows.

Values go through RecordFromValues, so a column the driver names JSON or JSONB arrives as the value its document describes and every other column keeps the shape it has always had -- driver []byte becomes a string.

This is the generic database/sql read path: db_lookup uses it in both key-column and query-template mode, and the editor's SQL builder falls back to it whenever a query binds arguments. Without the JSON half, a `jsonb` column reached transformations, sinks and the message trace as a string holding JSON, while the very same column fetched through pgx natively (PostgresSource.ExecuteSQL, and the source's snapshot and polling paths) arrived as a map. One document, two shapes, decided by which path fetched it.

func TemplateArgs added in v1.8.1

func TemplateArgs(tpl string, data map[string]any) []any

TemplateArgs returns the values a template's {{ ... }} tokens bind to, in placeholder order, resolved against data.

It is the same walk ParameterizeTemplateEx performs, minus the statement text, so a caller that needs to know "what does this template depend on in this message" cannot drift from what the executed query actually binds. The driver only decides placeholder syntax, which no such caller cares about, so there is no driver argument.

The use that motivated it is cache identity: a templated lookup's per-message input lives entirely in these values, and a cache keyed on the template text alone is the same key for every message.

func TemplateArgsWith added in v1.12.0

func TemplateArgsWith(tpl string, resolve Resolver) []any

TemplateArgsWith is TemplateArgs with the path resolution rule supplied by the caller.

Anything deriving a cache key from a template has to use the same rule the statement is built with, or the key can describe a different query from the one that runs.

func ValidateIdent

func ValidateIdent(name string) error

ValidateIdent verifies that an identifier (optionally schema/keyspace-qualified) contains only safe characters, has no empty segments, and respects the common length limit, without altering its quoting. Use it for engines (e.g. CQL) where identifiers are interpolated as-is, to prevent SQL/CQL injection.

Types

type ColumnMapping

type ColumnMapping struct {
	SourceField  string `json:"source_field"`
	TargetColumn string `json:"target_column"`
	DataType     string `json:"data_type"`      // Optional, used for auto-creation
	IsPrimaryKey bool   `json:"is_primary_key"` // Optional
	IsNullable   bool   `json:"is_nullable"`    // Optional
	IsIdentity   bool   `json:"is_identity"`    // Optional, auto-increment/sequence
}

ColumnMapping defines how a source field maps to a sink column.

func ParseColumnMappings

func ParseColumnMappings(s string) ([]ColumnMapping, error)

ParseColumnMappings parses a JSON string into a slice of ColumnMapping.

A blank source_field is treated as "use the column's own name": it falls back to target_column. Without this normalization an empty source_field resolves to a nil value at write time (evaluator.GetMsgValByPath returns nil for an empty path), so every such column is bound as NULL and rows fail on NOT NULL / PRIMARY KEY constraints. UIs commonly leave source_field blank to mean "same name as the target column", so this default makes that intent work safely.

type Resolver added in v1.12.0

type Resolver func(path string) any

Resolver answers what one token's path resolves to.

It exists because a SQL template used to be strictly weaker than every other template in Hermod. Tokens here resolved through GetFromMapPath, a literal walk of the data map, while a condition, a sink mapping or a db_lookup keyField resolved through evaluator.GetMsgValByPath, which also answers the CDC envelope (after., before.), the virtual fields (operation, table, schema) and meta.. The data map of a CDC message *is* its after-image, so `after.x` had nothing to walk and bound NULL -- while the editor's SQL builder, whose sample still carries the envelope, ran the same text and returned rows.

The rule is supplied by the caller rather than imported, because sqlutil sits deliberately below the transformer packages so that sources and sinks can use it; importing the evaluator here would invert that.

func MapResolver added in v1.12.0

func MapResolver(data map[string]any) Resolver

MapResolver resolves paths as a dotted walk of a plain map -- the behaviour every map-taking entry point below still has. It is the right rule for a caller that genuinely has no message: batch_sql binds from a `parameters` object on the source config, where an envelope path would mean nothing.

type TemplateBinding added in v1.8.1

type TemplateBinding struct {
	SQL  string
	Args []any
	// Unresolved holds the paths of tokens that resolved to nothing. Those
	// tokens are still bound -- as NULL -- because an optional message field is
	// a legitimate reason for a path to be empty. Callers whose variables come
	// from configuration rather than from a message should treat a non-empty
	// Unresolved as an error instead: binding NULL there turns a typo into a
	// query that silently matches no rows.
	Unresolved []string
	// Err is set when the template could not be turned into a statement at all
	// -- currently only when a list is too long to expand. The SQL and Args are
	// not usable when it is set.
	Err error
}

TemplateBinding is the result of turning a SQL template into a parameterized statement: the rewritten SQL, the bound arguments in placeholder order, and the token paths that resolved to nothing.

func ParameterizeTemplateEx added in v1.8.1

func ParameterizeTemplateEx(driver, tpl string, data map[string]any) TemplateBinding

ParameterizeTemplateEx is ParameterizeTemplate with the full binding result, resolving paths as a walk of data. See ParameterizeTemplateWith for how a list token is expanded.

func ParameterizeTemplateWith added in v1.12.0

func ParameterizeTemplateWith(driver, tpl string, resolve Resolver) TemplateBinding

ParameterizeTemplateWith is ParameterizeTemplateEx with the path resolution rule supplied by the caller. Every entry point above reaches the statement through here, so this is where the list rule lives.

A token whose value is a slice and which sits directly in an `IN (...)` list expands to one placeholder per element. Binding a slice to a single placeholder is not a list -- it is an encoding error on every driver ("unsupported type []interface {}, a slice of interface" through database/sql, "cannot find encode plan" through pgx) -- so `id IN ({{.ids}})` had no working form before this.

The expansion is deliberately confined to IN lists. `= ANY({{.ids}})` is the native Postgres array form and already works by binding the slice whole; expanding it would produce `= ANY($1, $2)`, a syntax error.

Jump to

Keyboard shortcuts

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