Files
pad/internal/store/dialect.go
T
xarmian fdce754f20 fix(store): an item list with a search term keeps include_archived and sort, and the tag filter matches exactly (BUG-3686, BUG-3706) (#2147)
BUG-3686: any list with a non-blank search runs listItemsFTS, which
hard-coded i.deleted_at IS NULL in both dialect branches and always
ordered by FTS rank, so include_archived and sort were silently ignored
on /items?search= and the collection item list. (Its dropped OFFSET and
unstable ties were BUG-3704's, fixed in #2143.) The archived clause now
follows params.IncludeArchived as ListItems does, and an explicit sort
goes through buildItemSort (which ends on i.id); relevance plus i.id
stays the default. Only the HTTP list parser sets Search, so no in-app
caller changes order.

BUG-3706: SQLite's JSONArrayContains was `tags LIKE '%"<tag>"%'`, so %
and _ in a tag were wildcards, case was ignored, and a tag holding a
quote never matched (it is stored escaped). Postgres built the JSON
argument by hand, so a quote made invalid JSON (ListItems errored: a
500) and `a","b` asked for two elements. SQLite now matches a json_each
element of type text exactly, on a valid JSON array only (a malformed
row matches nothing instead of failing the query); Postgres json-encodes
the argument. Tags are stored as written, so SQLite's tag filter is now
case-sensitive, as Postgres always was and as `pad item list --tag`
already documents ("exact match"). The legacy documents list uses the
same dialect call and gets the same fix.

Reproduced red on main on both dialects (TestBUG3686_*, TestBUG3706_*;
on Postgres the quote case is a ListItems error). Per-defect mutants on
SQLite each fail only their own test: the main dialect.go fails the tag
test, dropping the sort branch fails the sort test, dropping the
include_archived switch fails the archived test. Full internal/store
green; internal/server -run Item|Tag|List|Search|Document|Census|Guard
green; the new tests pass on make test-pg.

docs/api.md states what ?search= keeps and how tag matches.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 03:32:30 -04:00

458 lines
17 KiB
Go

package store
import (
"encoding/json"
"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
// JSONFieldText, JSONFieldEquals and JSONFieldOrder read an item field
// value by its JSON type, identically on both dialects (BUG-3221; the
// rule is in dialect_fieldvalue.go). Use them, not JSONExtractText,
// wherever a field value is compared, counted, grouped, sorted or shown.
JSONFieldText(column, key string) string
JSONFieldEquals(column, key, arg string) (string, []any)
// JSONFieldEqualsParts is JSONFieldEquals in two halves: the STRING
// branch, which leads with the bare extract compared to the argument so an
// expression index on that extract stays usable, and the non-string
// branch (numbers, booleans), "" when the argument can match neither.
JSONFieldEqualsParts(column, key, arg string) (str string, strArgs []any, other string, otherArgs []any)
JSONFieldOrder(column, key, dir 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, compared exactly (case and all).
// SQLite: a json_each element of type text equal to the arg, on a valid
// JSON array (a malformed row matches nothing rather than erroring).
// PostgreSQL: "column::jsonb @> ?::jsonb" with the arg json-encoded as a
// one-element array.
// BUG-3706: SQLite used LIKE '%"value"%', so % and _ in a tag were
// wildcards, case was ignored, and a tag holding a quote never matched;
// Postgres built the JSON by hand, so a quote made invalid JSON and
// `a","b` asked for two elements.
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{}) {
// The CASE keeps json_type/json_each away from a malformed row, which
// would otherwise fail the whole query.
return "(CASE WHEN json_valid(" + column + ") THEN (json_type(" + column + ") = 'array' AND EXISTS (SELECT 1 FROM json_each(" + column + ") je_tag WHERE je_tag.type = 'text' AND je_tag.value = ?)) ELSE 0 END)", 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{}) {
arg, _ := json.Marshal([]string{value}) // a []string always marshals
return column + "::jsonb @> ?::jsonb", string(arg)
}
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)
}
// "YYYY-MM-DDTHH:MM", for a zoned report in a zone whose offset is not a
// whole hour (TASK-3524, reports_tz.go).
if granularity == "minute" {
return fmt.Sprintf("SUBSTR(%s, 1, 16)", column)
}
return fmt.Sprintf("SUBSTR(%s, 1, 10)", column)
}
// ---------- Helper ----------
// rebindQuery converts "?" placeholders to PostgreSQL's "$1", "$2", etc.
// A "?" is a placeholder only in plain SQL text: it is copied unchanged inside
// a 'string literal' (where ” is an escaped quote), a "quoted identifier", a
// -- line comment or a /* block comment */.
//
// Comments are lexed for BUG-3430. A quote inside a comment ("the
// collection's items") used to flip the string state, so every later "?" was
// left unbound and the query failed on Postgres only; SQLite takes "?"
// natively and never comes here.
//
// NOT handled, deliberately: Postgres dollar-quoted strings ($$...$$,
// $tag$...$tag$) and E'...' escape strings (where \' does not close). A "?"
// inside either would be bound. No store SQL uses either form (audited for
// BUG-3430); write a plain '...' literal, or bind the value as an argument.
func rebindQuery(query string) string {
var buf strings.Builder
buf.Grow(len(query) + 16)
n := 0
for i := 0; i < len(query); i++ {
ch := query[i]
switch {
case ch == '\'' || ch == '"':
// Copy through the closing quote. A doubled quote ('' or "")
// closes and reopens, which copies the same bytes.
j := strings.IndexByte(query[i+1:], ch)
if j < 0 {
buf.WriteString(query[i:])
return buf.String()
}
buf.WriteString(query[i : i+j+2])
i += j + 1
case ch == '-' && i+1 < len(query) && query[i+1] == '-':
j := strings.IndexByte(query[i:], '\n')
if j < 0 {
buf.WriteString(query[i:])
return buf.String()
}
buf.WriteString(query[i : i+j]) // the newline is copied next turn
i += j - 1
case ch == '/' && i+1 < len(query) && query[i+1] == '*':
// Postgres block comments NEST: /* a /* b */ c */ is one comment.
end := blockCommentEnd(query, i)
if end < 0 {
buf.WriteString(query[i:])
return buf.String()
}
buf.WriteString(query[i:end])
i = end - 1
case ch == '?':
n++
fmt.Fprintf(&buf, "$%d", n)
default:
buf.WriteByte(ch)
}
}
return buf.String()
}
// blockCommentEnd returns the index just past the */ that closes the block
// comment opening at query[start], counting nested /* */ pairs as Postgres
// does, or -1 when it never closes.
func blockCommentEnd(query string, start int) int {
depth := 0
for i := start; i+1 < len(query); i++ {
switch {
case query[i] == '/' && query[i+1] == '*':
depth++
i++
case query[i] == '*' && query[i+1] == '/':
depth--
i++
if depth == 0 {
return i + 1
}
}
}
return -1
}