From e91cde6015c271169c234d11827311c88d9494fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:16:11 +0000 Subject: [PATCH 1/2] fix(db): rewrite ILIKE to LIKE for SQLite in the driver SQLite has no ILIKE operator, and unlike a missing function it cannot be polyfilled: ILIKE is a parser keyword, so SQLite rejects the statement before a user-defined function could ever run. The operator appears in roughly fifty places, but almost none can be fixed where they are written. buncolgen and querybuilder precompute their SQL fragments into package-level values at init, long before configuration is read, so a dialect-aware accessor would come too late, and most of buncolgen is generated anyway. That leaves 28 hand-written call sites which a helper could cover and 21 which it could not. Rewriting the statement as it reaches the driver is the one point that covers all of them, including generated code and anything added later. The SQLite handle now goes through a driver wrapper that substitutes the operator on the prepare path. Only the prepare path is implemented on purpose: database/sql falls back to Prepare when a connection does not advertise QueryerContext or ExecerContext, so no statement can slip past on a fast path. The substitution is sound because SQLite's LIKE is already case-insensitive for ASCII, which is what ILIKE is used for here. It is not equivalent for non-ASCII text, which is recorded in the docs as another reason SQLite is development-only. The rewrite skips string literals and quoted identifiers, so a stored value or column name containing the word is left alone; both cases are covered by tests, along with executing ILIKE against a real SQLite database. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXYnxBszfwkNUeeAcVSSNf --- docs/databases.md | 17 +- .../infrastructure/postgres/connection.go | 4 +- .../infrastructure/postgres/sqlitedriver.go | 215 ++++++++++++++++++ .../postgres/sqlitedriver_test.go | 120 ++++++++++ 4 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 services/tms/internal/infrastructure/postgres/sqlitedriver.go create mode 100644 services/tms/internal/infrastructure/postgres/sqlitedriver_test.go diff --git a/docs/databases.md b/docs/databases.md index f19fdd70e..4bdd8dca0 100644 --- a/docs/databases.md +++ b/docs/databases.md @@ -58,6 +58,7 @@ tables.** | `numeric(p,s)` | `REAL` (NUMERIC affinity demotes integers and breaks float64 scans) | | `bytea` | `BLOB` | | `EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint` | `(unixepoch())` | +| `ILIKE` | `LIKE`, rewritten in the driver (see below) | | `now()` | `CURRENT_TIMESTAMP` | | `TRIM(BOTH FROM x)` | `TRIM(x)` | | `char_length` | `length` | @@ -148,13 +149,27 @@ only emits those for zero values, and `BeforeAppendModel` sets the fields first. A file that is deliberately Postgres-only opts out with a `dialect:postgres-only` marker near its package clause, and must gate itself on a capability instead. +### ILIKE + +`ILIKE` is rewritten to `LIKE` in the SQLite driver rather than at the call site. +It appears in roughly fifty places, and almost none of them can be fixed where +they are written: `buncolgen` and `querybuilder` precompute their SQL fragments +into package-level values at init, before configuration is read, and most of +`buncolgen` is generated. The driver is the only single point that covers all of +them. + +The substitution is sound because SQLite's `LIKE` is already case-insensitive for +ASCII, which is what `ILIKE` is used for here. It is **not** equivalent for +non-ASCII text — one more reason SQLite is development-only. The rewrite skips +string literals and quoted identifiers, so a stored value containing the word is +left alone. + ### Known gaps not yet addressed These still contain Postgres-only SQL and will fail at runtime on SQLite: | Construct | Extent | Note | |---|---|---| -| `ILIKE` | 17 files | SQLite `LIKE` is already case-insensitive for ASCII, so this is a mechanical swap | | `::int`, `::float`, `::bigint`, `::numeric`, `::text`, `::jsonb` casts | ~109 occurrences | mostly in analytics and reporting aggregates | | `date_trunc` bucketing | reporting compiler, A/R analytics | marked `dialect:postgres-only`; needs a `strftime` equivalent | | `pg_stat_activity`, `pg_blocking_pids` | database session diagnostics | gated on `CapSessionDiagnostic`, returns 501 on SQLite | diff --git a/services/tms/internal/infrastructure/postgres/connection.go b/services/tms/internal/infrastructure/postgres/connection.go index 5f9bdd0e0..edffe3784 100644 --- a/services/tms/internal/infrastructure/postgres/connection.go +++ b/services/tms/internal/infrastructure/postgres/connection.go @@ -205,9 +205,9 @@ func (c *Connection) openDB( dsn string, ) (*sql.DB, schema.Dialect, error) { if dialect.IsSQLite() { - sqldb, err := sql.Open(sqliteDriverName, dsn) + sqldb, err := openSQLiteDB(dsn) if err != nil { - return nil, nil, fmt.Errorf("failed to open sqlite database: %w", err) + return nil, nil, err } return sqldb, sqlitedialect.New(), nil diff --git a/services/tms/internal/infrastructure/postgres/sqlitedriver.go b/services/tms/internal/infrastructure/postgres/sqlitedriver.go new file mode 100644 index 000000000..40aa49fd8 --- /dev/null +++ b/services/tms/internal/infrastructure/postgres/sqlitedriver.go @@ -0,0 +1,215 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "strings" + "sync" +) + +// SQLite has no ILIKE operator, and unlike a missing function it cannot be +// polyfilled: ILIKE is a parser keyword, so SQLite rejects the statement before +// any user-defined function could run. +// +// The operator appears in roughly fifty places, but almost none of them can be +// fixed at the call site. buncolgen and querybuilder precompute their SQL +// fragments into package-level values at init, long before configuration is +// read, and most of buncolgen is generated. Rewriting the statement as it +// reaches the driver is therefore the only single point that covers all of them. +// +// The substitution is sound because SQLite's LIKE is already case-insensitive +// for ASCII, which is what ILIKE is used for here. It is not equivalent for +// non-ASCII text, and that is one more reason SQLite is development-only. +const sqliteRewriteDriverName = "sqlite-trenova" + +var registerSQLiteRewriteDriver sync.Once + +// rewriteILike replaces the ILIKE operator with LIKE outside string literals and +// quoted identifiers, so a value or column name containing the word is untouched. +func rewriteILike(query string) string { + if !containsFold(query, "ilike") { + return query + } + + var ( + out strings.Builder + inSingle bool + inDouble bool + i int + ) + + out.Grow(len(query)) + + for i < len(query) { + ch := query[i] + + switch { + case inSingle: + if ch == '\'' { + inSingle = false + } + case inDouble: + if ch == '"' { + inDouble = false + } + case ch == '\'': + inSingle = true + case ch == '"': + inDouble = true + case (ch == 'i' || ch == 'I') && isILikeAt(query, i): + out.WriteString("LIKE") + i += len("ILIKE") + continue + } + + out.WriteByte(ch) + i++ + } + + return out.String() +} + +func isILikeAt(query string, i int) bool { + const keyword = "ilike" + + if i+len(keyword) > len(query) { + return false + } + + if !strings.EqualFold(query[i:i+len(keyword)], keyword) { + return false + } + + if i > 0 && isIdentifierByte(query[i-1]) { + return false + } + + end := i + len(keyword) + + return end == len(query) || !isIdentifierByte(query[end]) +} + +func isIdentifierByte(b byte) bool { + return b == '_' || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') +} + +func containsFold(haystack, needle string) bool { + return strings.Contains(strings.ToLower(haystack), needle) +} + +// openSQLiteDB returns a database handle whose statements pass through +// rewriteILike on the way to modernc.org/sqlite. +func openSQLiteDB(dsn string) (*sql.DB, error) { + base, err := sql.Open(sqliteDriverName, dsn) + if err != nil { + return nil, fmt.Errorf("failed to open sqlite database: %w", err) + } + + inner := base.Driver() + if closeErr := base.Close(); closeErr != nil { + return nil, fmt.Errorf("failed to close probe sqlite handle: %w", closeErr) + } + + registerSQLiteRewriteDriver.Do(func() { + sql.Register(sqliteRewriteDriverName, &rewriteDriver{inner: inner}) + }) + + return sql.OpenDB(&rewriteConnector{dsn: dsn, inner: inner}), nil +} + +type rewriteDriver struct { + inner driver.Driver +} + +func (d *rewriteDriver) Open(name string) (driver.Conn, error) { + conn, err := d.inner.Open(name) + if err != nil { + return nil, err + } + + return &rewriteConn{inner: conn}, nil +} + +type rewriteConnector struct { + dsn string + inner driver.Driver +} + +func (c *rewriteConnector) Connect(_ context.Context) (driver.Conn, error) { + conn, err := c.inner.Open(c.dsn) + if err != nil { + return nil, err + } + + return &rewriteConn{inner: conn}, nil +} + +func (c *rewriteConnector) Driver() driver.Driver { + return &rewriteDriver{inner: c.inner} +} + +// rewriteConn deliberately implements only the prepare path. database/sql falls +// back to Prepare when a connection does not advertise QueryerContext or +// ExecerContext, which keeps every statement going through the rewrite rather +// than slipping past it on a fast path. +type rewriteConn struct { + inner driver.Conn +} + +func (c *rewriteConn) Prepare(query string) (driver.Stmt, error) { + return c.inner.Prepare(rewriteILike(query)) +} + +func (c *rewriteConn) PrepareContext( + ctx context.Context, + query string, +) (driver.Stmt, error) { + if preparer, ok := c.inner.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, rewriteILike(query)) + } + + return c.inner.Prepare(rewriteILike(query)) +} + +func (c *rewriteConn) Close() error { return c.inner.Close() } + +func (c *rewriteConn) Begin() (driver.Tx, error) { //nolint:staticcheck // required by driver.Conn + return c.inner.Begin() //nolint:staticcheck // delegating to the wrapped driver +} + +func (c *rewriteConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + if beginner, ok := c.inner.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, opts) + } + + return c.inner.Begin() //nolint:staticcheck // fallback for drivers without BeginTx +} + +func (c *rewriteConn) Ping(ctx context.Context) error { + if pinger, ok := c.inner.(driver.Pinger); ok { + return pinger.Ping(ctx) + } + + return nil +} + +func (c *rewriteConn) ResetSession(ctx context.Context) error { + if resetter, ok := c.inner.(driver.SessionResetter); ok { + return resetter.ResetSession(ctx) + } + + return nil +} + +func (c *rewriteConn) IsValid() bool { + if validator, ok := c.inner.(driver.Validator); ok { + return validator.IsValid() + } + + return true +} diff --git a/services/tms/internal/infrastructure/postgres/sqlitedriver_test.go b/services/tms/internal/infrastructure/postgres/sqlitedriver_test.go new file mode 100644 index 000000000..984a37ebe --- /dev/null +++ b/services/tms/internal/infrastructure/postgres/sqlitedriver_test.go @@ -0,0 +1,120 @@ +package postgres + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRewriteILike(t *testing.T) { + tests := []struct { + name string + query string + want string + }{ + { + name: "leaves a query without the operator alone", + query: `SELECT * FROM t WHERE a = ?`, + want: `SELECT * FROM t WHERE a = ?`, + }, + { + name: "rewrites the operator", + query: `SELECT * FROM t WHERE name ILIKE ?`, + want: `SELECT * FROM t WHERE name LIKE ?`, + }, + { + name: "rewrites regardless of case", + query: `SELECT * FROM t WHERE name iLiKe ?`, + want: `SELECT * FROM t WHERE name LIKE ?`, + }, + { + name: "rewrites NOT ILIKE", + query: `SELECT * FROM t WHERE name NOT ILIKE ?`, + want: `SELECT * FROM t WHERE name NOT LIKE ?`, + }, + { + name: "rewrites every occurrence", + query: `SELECT * FROM t WHERE a ILIKE ? OR b ILIKE ?`, + want: `SELECT * FROM t WHERE a LIKE ? OR b LIKE ?`, + }, + { + name: "leaves string literals untouched", + query: `SELECT * FROM t WHERE note = ' ILIKE ' AND name ILIKE ?`, + want: `SELECT * FROM t WHERE note = ' ILIKE ' AND name LIKE ?`, + }, + { + name: "leaves quoted identifiers untouched", + query: `SELECT "ilike" FROM t WHERE name ILIKE ?`, + want: `SELECT "ilike" FROM t WHERE name LIKE ?`, + }, + { + name: "does not touch a column whose name merely contains the word", + query: `SELECT * FROM t WHERE ilike_count > ? AND unilike > ?`, + want: `SELECT * FROM t WHERE ilike_count > ? AND unilike > ?`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, rewriteILike(tt.query)) + }) + } +} + +func TestSQLiteDriverExecutesILike(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "ilike.db") + + db, err := openSQLiteDB("file:" + path) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, `CREATE TABLE people (name TEXT)`) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, `INSERT INTO people (name) VALUES ('Alice'), ('bob')`) + require.NoError(t, err) + + var name string + require.NoError( + t, + db.QueryRowContext(ctx, `SELECT name FROM people WHERE name ILIKE ?`, "alice").Scan(&name), + "ILIKE must survive the rewrite and execute on SQLite", + ) + assert.Equal(t, "Alice", name, "ILIKE must stay case-insensitive after the rewrite") + + require.NoError( + t, + db.QueryRowContext(ctx, `SELECT name FROM people WHERE name ILIKE ?`, "BOB").Scan(&name), + ) + assert.Equal(t, "bob", name) +} + +func TestSQLiteDriverPreservesLiteralContainingILike(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "literal.db") + + db, err := openSQLiteDB("file:" + path) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, `CREATE TABLE notes (body TEXT)`) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, `INSERT INTO notes (body) VALUES ('uses ILIKE here')`) + require.NoError(t, err) + + var body string + require.NoError( + t, + db.QueryRowContext(ctx, `SELECT body FROM notes WHERE body ILIKE ?`, "%ilike%").Scan(&body), + ) + assert.Equal( + t, + "uses ILIKE here", + body, + "a stored value containing ILIKE must not be rewritten", + ) +} From c37cdde0c1d6e05ddd97d54484ed732b5cd4e99d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:18:01 +0000 Subject: [PATCH 2/2] chore: stop tracking SQLite database sidecar files trenova.db-shm and trenova.db-wal were committed alongside the SQLite work. They are per-machine transient state that SQLite rewrites constantly, so they produce spurious diffs and merge conflicts for anyone running the stack locally. .gitignore already carried *.db, but that pattern does not match the shared memory and write-ahead log sidecars SQLite writes next to the database, since their names extend past the extension. Both are now listed explicitly, along with *.db-journal for non-WAL journal modes. The files are removed from the index only; the working copies stay in place so nobody's local database is disturbed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXYnxBszfwkNUeeAcVSSNf --- services/tms/.gitignore | 5 ++++- services/tms/trenova.db-shm | Bin 32768 -> 0 bytes services/tms/trenova.db-wal | Bin 24752 -> 0 bytes 3 files changed, 4 insertions(+), 1 deletion(-) delete mode 100644 services/tms/trenova.db-shm delete mode 100644 services/tms/trenova.db-wal diff --git a/services/tms/.gitignore b/services/tms/.gitignore index 8ab466a37..97c6a1d96 100644 --- a/services/tms/.gitignore +++ b/services/tms/.gitignore @@ -4,4 +4,7 @@ build .idea config/config.yaml/cli config/* -*.db \ No newline at end of file +*.db +*.db-shm +*.db-wal +*.db-journal diff --git a/services/tms/trenova.db-shm b/services/tms/trenova.db-shm deleted file mode 100644 index 8a1bdc743c06667f7b9f194f9acb51c84ee79bf2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*zX`%X6bIn*FP4_U$^{%kEEEK53lS9DzzHnvt!yj>XK)8+uoXM8)jMs{Di%S$ zAG}-=j+f&b;3YeELP|AODTHP{u8T$pyVa%L9o@#W$#Hodwg%hNZZls`57~$NQ}2}2 z_mRu%W$v<%AMeKJUaWqsd*xlZEp7=AAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t9|8Q2u#G4G9n+ zK%nRXalnrdAV7cs0RjXF5Fqg90%_8y5FkK+KoJFMuW8LiJee~QAV7cs0RjXF5FkK+ m009C72oNAZfB*pk1PJ__K>SUp5FkK+009C72oNCfrN9&D&?_DQ diff --git a/services/tms/trenova.db-wal b/services/tms/trenova.db-wal deleted file mode 100644 index 3310e1520090d2c12cbd84825aa0ecaa8aa82cfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24752 zcmeI3?Qh%09mgqKw&ggs)@4PW7WB3TXuKp6C6bnuy=bUsQPv}6QW6RTlys8lP^3aq zQsf2boCU=O?8W*Qv_OZgKrx`ekYL!bz1gc`Z;HLxuvZ&a^u@64#kyhWt6@jda^gU? zV+dHWRXa8mx|vzLDJp8EX{SG{w@!t%$&^2e*6p8oa9N2h*kpN>B;0ZafBzyvS>OaK$W z1pZG5?7r#xN+y*#&z#*|@KYC8S3MrL$L$^%`Yke$tqbX5xl%7>^QmkxOs9pY7!pXn zG1x!IN626(CT*-^Z)(OZR_#Cq8iC;Tt~SuO@BQ=>^j@*vyXJyT z;MMcH3jv1!agYEX4s8^Zd2xfQ#EUdT2_nWchloOL&?KfurI2OA-Th7Vz(shD756X@XTFzx;=8+-CjSu+hm>- zs#Gyc<#QWsDV&W*X72W6;G0ctpqhp)gT0>XD%aMOf@DS#WVSx47W%_UvYE^D!QZ^eN8tdQ`Xcex4?CDJ2*A=z|_VZlTAdk z#a<*?N^fn6v52r)=w-^;v0RODd|3^!k|GSK9hPqAm~dz_+m`H`!MESk2?<@8vk7Gn zCnU0|a6Mb!V2d0_1w$k$JUgL7CWQ&1O|66G1i5KUgQH9i?!oEhwo4+z=4%oY4R^)6 zl&;j6!cJRFbM^F5XIA~)3&r>tzQ}e}hI%C?? z2I@U){j^PdftxMr3_t&mpRb}a&x(ieHrIZ(c4_r{r$0HJUU?4{;1?!<319-4049J5 zU;>x`Ch!~+*gfZYDTVx$v)?>xJr&qJh338?QsX#2B!rVWI+?DfiXv5?`LU@`NhiuV z*=RyVkyNM+3;^#8NR8Az4iE?XdzpZ@T-E5$d~{&EVHdDb2g-eqrX?T>4Zyu03j_XF?mth)Gx319-4 z049J5U;>x`CV&ZG0+;|MfC+rD1g^NxW?Y(Oo*kXdI9*Iatd{c^@- znM+64TeRyTZ{YUP^%m@UZDhL~UC(Vts6*I3t}pZ7a9_;0(9^x;IUL>S+&Zedyk`U) zT@{UE>dAEk#qPJFKMvh_%k~AlZxh~sdO!94-usdFC*F6wZ+n07#g03^IwpV#U;>x` zCV&ZG0+;|MfC*p%m;fg5oD*1eyWOrys)6Ug-qL}+#V7YZTX=GB#eHCJ{=nYcX>^6< z$rT7<{@+jf0^9F=xA%k3#~)hj2$l$vAjs8Ut=wC_vG}?B(VTfW`vzJSS~5rR(NH83;|9ilUKSq?R>?FUtQSM2 zP)Z2pYtE{jn_qJ-eebac%y?AlvC-WT(MKrpoK18F<6$39De zY6mqa=@1}4%1Tc{OU8z1@z4+gWIUm4kp@~y)dE@@9qaYbk|L*NMefO_aSf=NiB6Oa zr}#BzJ20US6j92PYyt%>oCBuZgV(@NQskCwj<10>gy>vjfU;2(%{t!Iq0!Zp7Al2C z6D?l?x|L<7Lo3b9up>o&&=8^os)62yBUFSU&KD}6uk{DWrh+XWKq*yiS?>X&!vX8)Zv~SC$rgu*B4K&Aes{BjbwHDEL2Mgc_voH z3VLA_v~Y3-1*>#8M8ywL5LqgTsQ^~c3!@?~mg&88siEMW!BrXcJrVFk^ip#K6qor$;r