mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
ebe40de932
Dialect.OctetLength renders the byte length of a text-ish column as the driver will hand it to Scan. The "as Scan will see it" part is the whole reason this is a dialect method rather than a literal in one query. event_outbox.payload is TEXT on SQLite and JSONB on Postgres; octet_length has no jsonb overload, so Postgres needs a ::text cast, and that cast renders the PARSED value — whitespace normalized, keys reordered, duplicates collapsed — which is not the byte count that was written. Every caller of this is deciding whether it can afford to Scan a value, so the scanned count is the one they need and the stored one would quietly mislead. No behaviour change on its own; BUG-2827 is the first consumer. Claude-Session: https://claude.ai/code/session_01XLtX4dbjBpApbAv3SuBcTm
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// DriverType identifies the database backend.
|
|
type DriverType string
|
|
|
|
const (
|
|
DriverSQLite DriverType = "sqlite"
|
|
DriverPostgres DriverType = "postgres"
|
|
)
|
|
|
|
// Dialect encapsulates SQL syntax differences between database backends.
|
|
// The Store calls dialect methods to generate backend-specific SQL fragments.
|
|
type Dialect interface {
|
|
// Driver returns the driver type.
|
|
Driver() DriverType
|
|
|
|
// Placeholder returns the nth parameter placeholder (1-indexed).
|
|
// SQLite: "?", PostgreSQL: "$1", "$2", etc.
|
|
Placeholder(n int) string
|
|
|
|
// Rebind converts a query with "?" placeholders to the dialect's format.
|
|
// For SQLite this is a no-op. For PostgreSQL, "?" becomes "$1", "$2", etc.
|
|
Rebind(query string) string
|
|
|
|
// JSONExtractText returns SQL to extract a text value from a JSON column.
|
|
// SQLite: json_extract(col, '$.key')
|
|
// PostgreSQL: col->>'key'
|
|
JSONExtractText(column, key string) string
|
|
|
|
// JSONExtractPath returns SQL to extract a value at a dotted path from a JSON column.
|
|
// SQLite: json_extract(col, '$.path.to.key')
|
|
// PostgreSQL: col #>> '{path,to,key}'
|
|
JSONExtractPath(column, path string) string
|
|
|
|
// JSONSet returns SQL to set a value at a path in a JSON column.
|
|
// SQLite: json_set(col, '$.key', ?)
|
|
// PostgreSQL: jsonb_set(col::jsonb, '{key}', ?::jsonb)
|
|
// Returns the SQL fragment and any extra placeholders used.
|
|
JSONSet(column, key string) string
|
|
|
|
// JSONRemove returns SQL to remove a key from a JSON column.
|
|
// SQLite: json_remove(col, '$.key')
|
|
// PostgreSQL: col::jsonb - 'key'
|
|
JSONRemove(column, key string) string
|
|
|
|
// Now returns the SQL expression for the current UTC timestamp.
|
|
// SQLite: datetime('now')
|
|
// PostgreSQL: NOW() AT TIME ZONE 'UTC'
|
|
Now() string
|
|
|
|
// NowRFC3339 returns the SQL expression for current UTC time in RFC3339 format.
|
|
// SQLite: strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
// PostgreSQL: TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
NowRFC3339() string
|
|
|
|
// GroupConcat returns SQL for string aggregation with a separator.
|
|
// SQLite: GROUP_CONCAT(DISTINCT expr)
|
|
// PostgreSQL: STRING_AGG(DISTINCT expr, ',')
|
|
GroupConcat(expr string, distinct bool) string
|
|
|
|
// OctetLength returns SQL for the BYTE length of a text-ish column, as the
|
|
// driver will hand it to Scan.
|
|
//
|
|
// The distinction matters here rather than being pedantry: event_outbox
|
|
// payload is TEXT on SQLite and JSONB on Postgres, and JSONB is a parsed
|
|
// representation whose ::text rendering is normalized (whitespace stripped,
|
|
// object keys reordered, duplicate keys collapsed). So the stored byte count
|
|
// and the scanned byte count are not the same number on Postgres. This
|
|
// measures the SCANNED one on both dialects — octet_length of the TEXT for
|
|
// SQLite, octet_length of the ::text rendering for Postgres — because every
|
|
// caller of this is deciding whether it can afford to Scan the value, not
|
|
// how much disk it occupies.
|
|
//
|
|
// SQLite's octet_length() requires 3.43+; the bundled library is 3.53.3.
|
|
OctetLength(column string) string
|
|
|
|
// BoolToInt converts a Go bool to a query parameter value.
|
|
// SQLite: 0/1 (integers)
|
|
// PostgreSQL: true/false (native booleans)
|
|
BoolToInt(b bool) interface{}
|
|
|
|
// ILike returns the case-insensitive LIKE operator.
|
|
// SQLite: LIKE (case-insensitive by default)
|
|
// PostgreSQL: ILIKE
|
|
ILike() string
|
|
|
|
// Concat returns SQL to concatenate string expressions.
|
|
// SQLite: expr1 || expr2
|
|
// PostgreSQL: expr1 || expr2 (same, but useful as abstraction point)
|
|
Concat(exprs ...string) string
|
|
|
|
// FTSMatch returns the full-text search WHERE clause fragment.
|
|
// SQLite: "table MATCH ?" — consumes ONE arg.
|
|
// PostgreSQL: an OR-combined plainto_tsquery match that consumes TWO
|
|
// args — the raw user query AND its hyphen-sanitized form. See
|
|
// sanitizePGFTSQuery for the rationale (BUG-842).
|
|
FTSMatch(table, column string) string
|
|
|
|
// FTSSnippet returns SQL for highlighted search result snippets.
|
|
// SQLite: snippet(fts_table, col_idx, '<mark>', '</mark>', '...', 32)
|
|
// — consumes ONE arg.
|
|
// PostgreSQL: ts_headline backed by the same OR-combined tsquery as
|
|
// FTSMatch — consumes TWO args (raw, sanitized).
|
|
FTSSnippet(ftsTable string, colIndex int, sourceColumn string) string
|
|
|
|
// FTSRank returns the column/expression for full-text relevance ranking.
|
|
// SQLite: rank (built-in FTS5 column) — consumes ZERO args.
|
|
// PostgreSQL: ts_rank backed by the same OR-combined tsquery as
|
|
// FTSMatch — consumes TWO args (raw, sanitized).
|
|
FTSRank(table, column string) string
|
|
|
|
// JSONArrayContains returns SQL + the arg to check if a JSON array column
|
|
// contains a given text value.
|
|
// SQLite: "column LIKE ?" with arg `%"value"%`
|
|
// PostgreSQL: "column::jsonb @> ?::jsonb" with arg `["value"]`
|
|
JSONArrayContains(column, value string) (string, interface{})
|
|
|
|
// JSONArrayElements returns a FROM-clause table-function fragment that
|
|
// unnests a JSON text-array column into one row per element, plus the
|
|
// expression yielding each element's text value. Intended as a comma
|
|
// cross-join: `FROM items i, <fromExpr> WHERE ...` selecting <valueExpr>.
|
|
// SQLite: fromExpr "json_each(i.tags) je", valueExpr "je.value"
|
|
// PostgreSQL: fromExpr "jsonb_array_elements_text(i.tags::jsonb) AS je(value)", valueExpr "je.value"
|
|
JSONArrayElements(column, alias string) (fromExpr, valueExpr string)
|
|
|
|
// DateBucket returns a SQL expression that truncates an RFC3339 UTC
|
|
// timestamp TEXT column to the start of its bucket, as a sortable TEXT
|
|
// label, for GROUP BY in time-series reports (PLAN-1628). granularity is
|
|
// "hour" or "day".
|
|
//
|
|
// Implemented via fixed-width substring on both engines: every timestamp
|
|
// in the schema is stored as UTC RFC3339 ("YYYY-MM-DDTHH:MM:SSZ", see
|
|
// dialect.NowRFC3339), so "day" = chars 1-10 and "hour" = chars 1-13 are
|
|
// exact and identical across SQLite and Postgres — and avoid SQLite's
|
|
// fragile parsing of the trailing 'Z'. Coarser granularities (week/month)
|
|
// need real date math and would diverge here to strftime (SQLite) vs
|
|
// date_trunc/to_char (Postgres); they're added when a window needs them.
|
|
DateBucket(column, granularity string) string
|
|
}
|
|
|
|
// ---------- SQLite dialect ----------
|
|
|
|
type sqliteDialect struct{}
|
|
|
|
func (d *sqliteDialect) Driver() DriverType { return DriverSQLite }
|
|
|
|
func (d *sqliteDialect) Placeholder(_ int) string { return "?" }
|
|
|
|
func (d *sqliteDialect) Rebind(query string) string { return query }
|
|
|
|
func (d *sqliteDialect) JSONExtractText(column, key string) string {
|
|
return fmt.Sprintf("json_extract(%s, '$.%s')", column, key)
|
|
}
|
|
|
|
func (d *sqliteDialect) JSONExtractPath(column, path string) string {
|
|
return fmt.Sprintf("json_extract(%s, '$.%s')", column, path)
|
|
}
|
|
|
|
func (d *sqliteDialect) JSONSet(column, key string) string {
|
|
return fmt.Sprintf("json_set(%s, '$.%s', ?)", column, key)
|
|
}
|
|
|
|
func (d *sqliteDialect) JSONRemove(column, key string) string {
|
|
return fmt.Sprintf("json_remove(%s, '$.%s')", column, key)
|
|
}
|
|
|
|
func (d *sqliteDialect) Now() string {
|
|
return "datetime('now')"
|
|
}
|
|
|
|
func (d *sqliteDialect) NowRFC3339() string {
|
|
return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
|
}
|
|
|
|
func (d *sqliteDialect) GroupConcat(expr string, distinct bool) string {
|
|
if distinct {
|
|
return fmt.Sprintf("GROUP_CONCAT(DISTINCT %s)", expr)
|
|
}
|
|
return fmt.Sprintf("GROUP_CONCAT(%s)", expr)
|
|
}
|
|
|
|
func (d *sqliteDialect) OctetLength(column string) string {
|
|
return fmt.Sprintf("octet_length(%s)", column)
|
|
}
|
|
|
|
func (d *sqliteDialect) BoolToInt(b bool) interface{} {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (d *sqliteDialect) ILike() string { return "LIKE" }
|
|
|
|
func (d *sqliteDialect) Concat(exprs ...string) string {
|
|
return strings.Join(exprs, " || ")
|
|
}
|
|
|
|
func (d *sqliteDialect) FTSMatch(table, _ string) string {
|
|
return fmt.Sprintf("%s MATCH ?", table)
|
|
}
|
|
|
|
func (d *sqliteDialect) FTSSnippet(ftsTable string, colIndex int, _ string) string {
|
|
return fmt.Sprintf("snippet(%s, %d, '<mark>', '</mark>', '...', 32)", ftsTable, colIndex)
|
|
}
|
|
|
|
func (d *sqliteDialect) FTSRank(_, _ string) string {
|
|
return "rank"
|
|
}
|
|
|
|
func (d *sqliteDialect) JSONArrayContains(column, value string) (string, interface{}) {
|
|
return column + " LIKE ?", "%\"" + value + "\"%"
|
|
}
|
|
|
|
func (d *sqliteDialect) JSONArrayElements(column, alias string) (string, string) {
|
|
return "json_each(" + column + ") " + alias, alias + ".value"
|
|
}
|
|
|
|
func (d *sqliteDialect) DateBucket(column, granularity string) string {
|
|
return dateBucketSubstr(column, granularity)
|
|
}
|
|
|
|
// ---------- PostgreSQL dialect ----------
|
|
|
|
type postgresDialect struct{}
|
|
|
|
func (d *postgresDialect) Driver() DriverType { return DriverPostgres }
|
|
|
|
func (d *postgresDialect) Placeholder(n int) string {
|
|
return fmt.Sprintf("$%d", n)
|
|
}
|
|
|
|
func (d *postgresDialect) Rebind(query string) string {
|
|
return rebindQuery(query)
|
|
}
|
|
|
|
func (d *postgresDialect) JSONExtractText(column, key string) string {
|
|
return fmt.Sprintf("%s->>'%s'", column, key)
|
|
}
|
|
|
|
func (d *postgresDialect) JSONExtractPath(column, path string) string {
|
|
parts := strings.Split(path, ".")
|
|
return fmt.Sprintf("%s #>> '{%s}'", column, strings.Join(parts, ","))
|
|
}
|
|
|
|
func (d *postgresDialect) JSONSet(column, key string) string {
|
|
return fmt.Sprintf("jsonb_set(COALESCE(%s, '{}')::jsonb, '{%s}', to_jsonb(?::text))", column, key)
|
|
}
|
|
|
|
func (d *postgresDialect) JSONRemove(column, key string) string {
|
|
return fmt.Sprintf("(%s::jsonb - '%s')", column, key)
|
|
}
|
|
|
|
func (d *postgresDialect) Now() string {
|
|
return "(NOW() AT TIME ZONE 'UTC')"
|
|
}
|
|
|
|
func (d *postgresDialect) NowRFC3339() string {
|
|
return "TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
|
}
|
|
|
|
func (d *postgresDialect) GroupConcat(expr string, distinct bool) string {
|
|
if distinct {
|
|
return fmt.Sprintf("STRING_AGG(DISTINCT %s, ',')", expr)
|
|
}
|
|
return fmt.Sprintf("STRING_AGG(%s, ',')", expr)
|
|
}
|
|
|
|
func (d *postgresDialect) OctetLength(column string) string {
|
|
// The ::text cast is load-bearing, not defensive: octet_length has no jsonb
|
|
// overload, so without it this is a type error rather than a wrong number.
|
|
return fmt.Sprintf("octet_length(%s::text)", column)
|
|
}
|
|
|
|
func (d *postgresDialect) BoolToInt(b bool) interface{} {
|
|
return b
|
|
}
|
|
|
|
func (d *postgresDialect) ILike() string { return "ILIKE" }
|
|
|
|
func (d *postgresDialect) Concat(exprs ...string) string {
|
|
return strings.Join(exprs, " || ")
|
|
}
|
|
|
|
// PG FTS uses an OR-combined plainto_tsquery to handle hyphenated user
|
|
// queries correctly. For an indexed title `task-five-distinctive`, the
|
|
// english parser writes `task-five-distinct, task, five, distinct`. A
|
|
// raw `plainto_tsquery('english', 'task-five')` produces
|
|
// `task-fiv & task & five` — the stemmed asciihword `task-fiv` is NOT in
|
|
// the vector, so the AND fails and the search returns 0 rows. Replacing
|
|
// the hyphen with a space (`'task five'`) makes plainto emit
|
|
// `task & five`, which DOES match. We can't unconditionally do that,
|
|
// though: titles like `BUG-842` are indexed as `bug, -842` (negative
|
|
// number lexeme) — `plainto_tsquery('BUG-842')` matches them via `-842`,
|
|
// but `plainto_tsquery('BUG 842')` would search for the lexeme `842` and
|
|
// miss. ORing the two query variants together covers both cases. See
|
|
// BUG-842 and sanitizePGFTSQuery.
|
|
//
|
|
// Each method below consumes TWO `?` placeholders (raw query, sanitized
|
|
// query). Callers pass them in args via sanitizePGFTSQueryArgs.
|
|
func (d *postgresDialect) FTSMatch(table, column string) string {
|
|
return fmt.Sprintf(
|
|
"%s.%s @@ (plainto_tsquery('english', ?) || plainto_tsquery('english', ?))",
|
|
table, column,
|
|
)
|
|
}
|
|
|
|
func (d *postgresDialect) FTSSnippet(_ string, _ int, sourceColumn string) string {
|
|
return fmt.Sprintf(
|
|
"ts_headline('english', %s, plainto_tsquery('english', ?) || plainto_tsquery('english', ?), 'StartSel=<mark>,StopSel=</mark>,MaxFragments=1,MaxWords=32')",
|
|
sourceColumn,
|
|
)
|
|
}
|
|
|
|
func (d *postgresDialect) FTSRank(table, column string) string {
|
|
return fmt.Sprintf(
|
|
"ts_rank(%s.%s, plainto_tsquery('english', ?) || plainto_tsquery('english', ?))",
|
|
table, column,
|
|
)
|
|
}
|
|
|
|
func (d *postgresDialect) JSONArrayContains(column, value string) (string, interface{}) {
|
|
return column + "::jsonb @> ?::jsonb", `["` + value + `"]`
|
|
}
|
|
|
|
func (d *postgresDialect) JSONArrayElements(column, alias string) (string, string) {
|
|
return "jsonb_array_elements_text(" + column + "::jsonb) AS " + alias + "(value)", alias + ".value"
|
|
}
|
|
|
|
func (d *postgresDialect) DateBucket(column, granularity string) string {
|
|
return dateBucketSubstr(column, granularity)
|
|
}
|
|
|
|
// dateBucketSubstr truncates a UTC RFC3339 TEXT timestamp to its day ("hour"
|
|
// → chars 1-13 "YYYY-MM-DDTHH"; otherwise → chars 1-10 "YYYY-MM-DD"). The
|
|
// SUBSTR(text, from, count) form is valid and identical on SQLite and
|
|
// Postgres, so both dialects delegate here for these granularities.
|
|
func dateBucketSubstr(column, granularity string) string {
|
|
if granularity == "hour" {
|
|
return fmt.Sprintf("SUBSTR(%s, 1, 13)", column)
|
|
}
|
|
return fmt.Sprintf("SUBSTR(%s, 1, 10)", column)
|
|
}
|
|
|
|
// ---------- Helper ----------
|
|
|
|
// rebindQuery converts "?" placeholders to PostgreSQL's "$1", "$2", etc.
|
|
// Respects string literals (single quotes) and does not modify "?" inside them.
|
|
func rebindQuery(query string) string {
|
|
var buf strings.Builder
|
|
buf.Grow(len(query) + 16)
|
|
n := 0
|
|
inString := false
|
|
for i := 0; i < len(query); i++ {
|
|
ch := query[i]
|
|
if ch == '\'' {
|
|
inString = !inString
|
|
buf.WriteByte(ch)
|
|
} else if ch == '?' && !inString {
|
|
n++
|
|
fmt.Fprintf(&buf, "$%d", n)
|
|
} else {
|
|
buf.WriteByte(ch)
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|