diff --git a/dialect/dialect.go b/dialect/dialect.go index 7077f42..a34c812 100644 --- a/dialect/dialect.go +++ b/dialect/dialect.go @@ -47,37 +47,30 @@ type Dialect interface { // equivalent (PostgreSQL — use OnConflict…DoNothing instead). InsertIgnoreClause() string + // SupportsIgnoreConflicts reports whether the shared IgnoreConflicts helper + // has explicitly classified this dialect's no-op conflict behavior. Custom + // dialects should return false until their semantics are reviewed. + SupportsIgnoreConflicts() bool + // SupportsCTE reports whether the dialect supports Common Table Expressions // (WITH clauses). True for PostgreSQL, MySQL 8.0+, and SQLite 3.8.3+. // - // When false, the query builder omits the WITH clause at build time. Any - // FROM or JOIN reference to a CTE name (via CTERef) remains in the SQL as a - // plain table name, which will produce a runtime database error (unknown table). - // This is intentional: failing loudly is safer than silently returning wrong - // results. Custom dialects targeting engines older than these versions should - // return false. + // When false, builders requested to render a CTE return an + // unsupported_feature build error. SupportsCTE() bool // SupportsWindowFunctions reports whether the dialect supports window // functions (OVER clause). True for PostgreSQL, MySQL 8.0+, and SQLite 3.25+. // - // When false, the query builder drops only the window function columns from - // the SELECT list at build time; non-window columns are preserved as-is. - // If every column in the SELECT list is a window function (i.e. no non-window - // columns remain after dropping), the query falls back to SELECT *. In that - // case SELECT * returns all table columns, including any that were - // intentionally excluded from the original SELECT list — callers that rely - // on column restriction for data access control should check this flag before - // building such queries. + // When false, builders requested to render a window expression return an + // unsupported_feature build error. SupportsWindowFunctions() bool // SupportsDistinctOn reports whether the dialect supports SELECT DISTINCT ON // (expr, ...). This is a PostgreSQL extension; MySQL and SQLite do not support it. // - // When false, DistinctOn() degrades to regular SELECT DISTINCT at build time. - // This is a semantic change: DISTINCT ON returns one row per distinct-on group - // (using ORDER BY to pick which row), whereas DISTINCT deduplicates across all - // selected columns. Query results will differ in most cases. + // When false, builders requested to render DistinctOn return an + // unsupported_feature build error. SupportsDistinctOn() bool // SupportsForUpdate reports whether the dialect supports row-level locking. @@ -86,8 +79,8 @@ type Dialect interface { // // Note: the shared-lock syntax differs by dialect — PostgreSQL uses FOR SHARE // while MySQL uses LOCK IN SHARE MODE (see ForShareClause). The query builder - // gates all row-level locking clauses on this flag; when false, locking clauses - // are silently dropped from the output SQL. + // gates all row-level locking clauses on this flag; when false, requested + // locking returns an unsupported_feature build error. SupportsForUpdate() bool // SupportsForNoKeyUpdate reports whether the dialect supports the @@ -100,11 +93,19 @@ type Dialect interface { // SupportsFullJoin reports whether the dialect supports FULL [OUTER] JOIN. // True for PostgreSQL; false for MySQL and SQLite. // - // When false, the query builder silently drops FULL JOIN clauses at build time. - // This is a semantic change: rows that would have been included via the outer - // side of the join are omitted entirely from the result set. + // When false, requested FULL JOIN clauses return an unsupported_feature build + // error. SupportsFullJoin() bool + // SupportsRightJoin reports whether the dialect supports RIGHT [OUTER] JOIN. + // PostgreSQL and MySQL support it. The built-in SQLite dialect returns false + // because its 3.35+ baseline cannot guarantee the feature added in 3.39. + // Callers with a version-aware SQLite dialect can report true. + // + // When false, requested RIGHT JOIN clauses return an unsupported_feature + // build error. + SupportsRightJoin() bool + // ForShareClause returns the SQL keyword phrase for a shared row lock. // PostgreSQL: "FOR SHARE". MySQL: "LOCK IN SHARE MODE". // Returns "" for dialects that do not support row-level locking (e.g. SQLite). @@ -121,28 +122,23 @@ type Dialect interface { // regular expression match operators (~, ~*, !~, !~*). // True for PostgreSQL only; false for MySQL and SQLite. // - // When false, all four operators — including the NOT-match operators (!~, !~*) — - // emit FALSE in the SQL output. Note: NOT-match operators emit FALSE (no rows), - // not TRUE (all rows), so expr.Not(col.NotRegexpMatch(...)) yields TRUE on - // unsupported dialects. Callers should check this flag before using regex operators. + // When false, rendering any of these operators returns an + // unsupported_feature error without binding arguments. SupportsRegexpMatch() bool // SupportsFullTextSearch reports whether the dialect supports PostgreSQL-style // full-text search operators and functions (@@, to_tsvector, to_tsquery, etc.). // True for PostgreSQL only; false for MySQL and SQLite. // - // When false, FTS expression types emit FALSE (for predicates) or NULL (for - // scalar expressions such as standalone tsquery constructors) in the SQL output. - // Callers should check this flag before building queries with FTS operators. + // When false, rendering PostgreSQL-style FTS expressions returns an + // unsupported_feature error without binding arguments. SupportsFullTextSearch() bool // SupportsLimitOnMutate reports whether the dialect supports a LIMIT clause // on UPDATE and DELETE statements. True for MySQL and SQLite; false for // PostgreSQL, which does not support LIMIT on mutating statements. // - // When false, the LIMIT clause is silently dropped from UPDATE and DELETE - // statements at Build() time. The query builder consults this flag in - // UpdateBuilder.Build() and DeleteBuilder.Build(). + // When false, requested mutation limits return an unsupported_feature error. // // SQLite note: LIMIT on UPDATE/DELETE requires the SQLite library to be // compiled with SQLITE_ENABLE_UPDATE_DELETE_LIMIT. This flag is enabled @@ -165,12 +161,14 @@ func (postgresDialect) Name() string { return "postgres" } func (postgresDialect) SupportsReturning() bool { return true } func (postgresDialect) UpsertStyle() UpsertStyle { return UpsertOnConflict } func (postgresDialect) InsertIgnoreClause() string { return "" } // use ON CONFLICT … DO NOTHING +func (postgresDialect) SupportsIgnoreConflicts() bool { return true } func (postgresDialect) SupportsCTE() bool { return true } func (postgresDialect) SupportsWindowFunctions() bool { return true } func (postgresDialect) SupportsDistinctOn() bool { return true } func (postgresDialect) SupportsForUpdate() bool { return true } func (postgresDialect) SupportsForNoKeyUpdate() bool { return true } func (postgresDialect) SupportsFullJoin() bool { return true } +func (postgresDialect) SupportsRightJoin() bool { return true } func (postgresDialect) ForShareClause() string { return "FOR SHARE" } func (postgresDialect) SupportsForShareOf() bool { return true } func (postgresDialect) SupportsRegexpMatch() bool { return true } @@ -199,12 +197,14 @@ func (mysqlDialect) Name() string { return "mysql" } func (mysqlDialect) SupportsReturning() bool { return false } func (mysqlDialect) UpsertStyle() UpsertStyle { return UpsertDuplicateKey } func (mysqlDialect) InsertIgnoreClause() string { return "INSERT IGNORE" } +func (mysqlDialect) SupportsIgnoreConflicts() bool { return true } func (mysqlDialect) SupportsCTE() bool { return true } // MySQL 8.0+ func (mysqlDialect) SupportsWindowFunctions() bool { return true } // MySQL 8.0+ func (mysqlDialect) SupportsDistinctOn() bool { return false } func (mysqlDialect) SupportsForUpdate() bool { return true } func (mysqlDialect) SupportsForNoKeyUpdate() bool { return false } func (mysqlDialect) SupportsFullJoin() bool { return false } +func (mysqlDialect) SupportsRightJoin() bool { return true } func (mysqlDialect) ForShareClause() string { return "LOCK IN SHARE MODE" } func (mysqlDialect) SupportsForShareOf() bool { return false } func (mysqlDialect) SupportsRegexpMatch() bool { return false } @@ -230,12 +230,14 @@ func (sqliteDialect) Name() string { return "sqlite" } func (sqliteDialect) SupportsReturning() bool { return true } // SQLite 3.35+ func (sqliteDialect) UpsertStyle() UpsertStyle { return UpsertOnConflict } func (sqliteDialect) InsertIgnoreClause() string { return "INSERT OR IGNORE" } +func (sqliteDialect) SupportsIgnoreConflicts() bool { return true } func (sqliteDialect) SupportsCTE() bool { return true } // SQLite 3.8.3+ func (sqliteDialect) SupportsWindowFunctions() bool { return true } // SQLite 3.25+ func (sqliteDialect) SupportsDistinctOn() bool { return false } func (sqliteDialect) SupportsForUpdate() bool { return false } func (sqliteDialect) SupportsForNoKeyUpdate() bool { return false } func (sqliteDialect) SupportsFullJoin() bool { return false } +func (sqliteDialect) SupportsRightJoin() bool { return false } func (sqliteDialect) ForShareClause() string { return "" } func (sqliteDialect) SupportsForShareOf() bool { return false } func (sqliteDialect) SupportsRegexpMatch() bool { return false } diff --git a/dialect/dialect_test.go b/dialect/dialect_test.go index 5196763..ea87399 100644 --- a/dialect/dialect_test.go +++ b/dialect/dialect_test.go @@ -18,11 +18,13 @@ func TestDialectFeatureMatrix(t *testing.T) { supportsForUpdate bool supportsForNoKey bool supportsFullJoin bool + supportsRightJoin bool supportsForShareOf bool supportsLimitOnMutate bool forShareClause string supportsRegexpMatch bool supportsFullTextSearch bool + supportsIgnoreConflict bool } cases := []row{ @@ -35,11 +37,13 @@ func TestDialectFeatureMatrix(t *testing.T) { supportsForUpdate: true, supportsForNoKey: true, supportsFullJoin: true, + supportsRightJoin: true, supportsForShareOf: true, supportsLimitOnMutate: false, forShareClause: "FOR SHARE", supportsRegexpMatch: true, supportsFullTextSearch: true, + supportsIgnoreConflict: true, }, { name: "mysql", @@ -50,11 +54,13 @@ func TestDialectFeatureMatrix(t *testing.T) { supportsForUpdate: true, supportsForNoKey: false, supportsFullJoin: false, + supportsRightJoin: true, supportsForShareOf: false, supportsLimitOnMutate: true, forShareClause: "LOCK IN SHARE MODE", supportsRegexpMatch: false, supportsFullTextSearch: false, + supportsIgnoreConflict: true, }, { name: "sqlite", @@ -65,11 +71,13 @@ func TestDialectFeatureMatrix(t *testing.T) { supportsForUpdate: false, supportsForNoKey: false, supportsFullJoin: false, + supportsRightJoin: false, supportsForShareOf: false, supportsLimitOnMutate: true, forShareClause: "", supportsRegexpMatch: false, supportsFullTextSearch: false, + supportsIgnoreConflict: true, }, } @@ -93,11 +101,13 @@ func TestDialectFeatureMatrix(t *testing.T) { checkBool("SupportsForUpdate", c.d.SupportsForUpdate(), c.supportsForUpdate) checkBool("SupportsForNoKeyUpdate", c.d.SupportsForNoKeyUpdate(), c.supportsForNoKey) checkBool("SupportsFullJoin", c.d.SupportsFullJoin(), c.supportsFullJoin) + checkBool("SupportsRightJoin", c.d.SupportsRightJoin(), c.supportsRightJoin) checkBool("SupportsForShareOf", c.d.SupportsForShareOf(), c.supportsForShareOf) checkBool("SupportsLimitOnMutate", c.d.SupportsLimitOnMutate(), c.supportsLimitOnMutate) checkStr("ForShareClause", c.d.ForShareClause(), c.forShareClause) checkBool("SupportsRegexpMatch", c.d.SupportsRegexpMatch(), c.supportsRegexpMatch) checkBool("SupportsFullTextSearch", c.d.SupportsFullTextSearch(), c.supportsFullTextSearch) + checkBool("SupportsIgnoreConflicts", c.d.SupportsIgnoreConflicts(), c.supportsIgnoreConflict) }) } } diff --git a/docs/advanced/subqueries.md b/docs/advanced/subqueries.md index 35d4392..7169559 100644 --- a/docs/advanced/subqueries.md +++ b/docs/advanced/subqueries.md @@ -1,8 +1,7 @@ # Subqueries -::: warning Target query API -Examples on this page use the target error-returning `Build(dialect)` contract and target fail-fast dialect behavior. The current branch may still expose the older two-return shape; any silent omission of unsupported CTE SQL is non-conforming implementation debt until those query contracts land. -::: +Examples on this page use the error-returning `Build(dialect)` contract. +Unsupported requested features fail with a build error rather than being omitted. Subquery helpers live in the `query` package. They let you compose SELECT builders into correlated or uncorrelated sub-expressions. diff --git a/docs/advanced/window-functions.md b/docs/advanced/window-functions.md index fcfbd7f..399a0c9 100644 --- a/docs/advanced/window-functions.md +++ b/docs/advanced/window-functions.md @@ -1,8 +1,7 @@ # Window Functions -::: warning Target query API -Examples on this page use the target error-returning `Build(dialect)` and fail-fast unsupported-feature behavior. The current branch may still expose older two-return builders; any silent dialect fallback is non-conforming implementation debt until those target query contracts land. -::: +Examples on this page use the error-returning `Build(dialect)` and fail-fast +unsupported-feature behavior. Window expressions (`fn OVER (PARTITION BY … ORDER BY …)`) are in the `expr` package. They implement `SelectableColumn` so they can appear in SELECT and ORDER BY. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 2990cc5..4100b54 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -23,8 +23,8 @@ Grizzle has three layers: | Layer | Package | What it does | |---|---|---| | Schema DSL | `schema/pg` | Declare tables and columns in Go | -| Query builders | `query`, `expr` | Build type-safe SQL. Target API: `Build(dialect)` returns `(string, []any, error)`; current branch may still expose the older two-return shape. | -| Driver adapter | `driver/pgx` | Target behavior: execute builders against a `pgxpool.Pool` and surface build errors before execution after the error-returning `Build` contract lands | +| Query builders | `query`, `expr` | Build type-safe SQL. `Build(dialect)` returns `(string, []any, error)`. | +| Driver adapter | `driver/pgx` | Execute builders against a `pgxpool.Pool` and surface build errors before execution. | Code generation bridges the first two layers: `grizzle gen` reads your `schema/pg` declarations and emits typed table handles (`UsersT`, `RealmsT`, …) that the query builders consume. diff --git a/docs/guide/mutations.md b/docs/guide/mutations.md index 902bd3c..26330be 100644 --- a/docs/guide/mutations.md +++ b/docs/guide/mutations.md @@ -118,7 +118,7 @@ query.MySQLInsertInto(mysqlschema.UsersT). OnDuplicateKeyUpdateSet(query.MySQLSetColSelf(mysqlschema.UsersT.ID)) ``` -`IgnoreConflicts()` is an optional shared wrapper. If retained, it must render `ON CONFLICT DO NOTHING` for PostgreSQL and SQLite, `INSERT IGNORE` for MySQL, and a build error for unsupported/custom dialects. +`IgnoreConflicts()` is an optional shared wrapper. It renders `ON CONFLICT DO NOTHING` for PostgreSQL and SQLite and `INSERT IGNORE` for MySQL. Unsupported or custom dialects whose no-op conflict semantics have not been explicitly classified return a build error. ::: ::: warning diff --git a/docs/guide/querying.md b/docs/guide/querying.md index 98bb294..f7726cf 100644 --- a/docs/guide/querying.md +++ b/docs/guide/querying.md @@ -1,8 +1,8 @@ # Querying -::: warning Target query API -This guide describes the RC.1-parity target query API. The current branch still has implementation gaps: `Build(dialect)` may return only `(sql, args)`, and any silent degradation of unsupported dialect features is non-conforming implementation debt until the error-returning build contract and fail-fast dialect gates are implemented. -::: +All query builders return `(sql, args, err)` from `Build(dialect)`. Callers must +check `err` before using the SQL or arguments; failed builds return no executable +SQL or argument slice. All query builders are in the `query` and `expr` packages. The target Go API may keep immutable/value-copy builders for aliasing safety, but receiver mutability is a Go implementation choice; the parity requirement is the rendered SQL behavior. @@ -204,7 +204,7 @@ rows, err := d.Query(ctx, query.Select().From(db.UsersT)) users, err := pgxdb.ScanAll[db.UserSelect](rows, err) ``` -`ScanAll`, `ScanOne`, and `ScanOneOpt` own and close non-nil, non-typed-nil row sets. They return build/query errors before scanning, preserve context cancellation/deadline sentinels, and return redacted stable cardinality errors for zero-or-many rows where the helper requires exactly one row. +`ScanAll`, `ScanOne`, and `ScanOneOpt` accept the error returned by `Query` and return it before scanning. Consult each driver helper's API for its current row-closing and cardinality behavior. ## Prepared queries diff --git a/docs/reference/dialects.md b/docs/reference/dialects.md index cce3250..82a9946 100644 --- a/docs/reference/dialects.md +++ b/docs/reference/dialects.md @@ -2,9 +2,8 @@ The `dialect` package defines the `Dialect` interface and provides three built-in implementations. Every query builder accepts a dialect when producing final SQL, which keeps the same builder code portable across database engines. -::: warning Target dialect behavior -The comparison table and examples on this page describe the RC.1-parity target behavior, including fail-fast unsupported features and the error-returning `Build(dialect)` contract. The current branch may still expose older method names or two-return builders; any silent dialect fallback is non-conforming implementation debt until fail-fast gates land. -::: +Query builds fail fast when a requested feature is unsupported and return +`(sql, args, err)` from `Build(dialect)`. ## Built-in dialects @@ -18,7 +17,7 @@ dialect.SQLite // SQLite 3.35+ baseline; RIGHT/FULL JOIN requires SQLite 3.39 ## Comparison -SQLite RIGHT/FULL OUTER JOIN support starts in SQLite 3.39.0, so Grizzle's SQLite `SupportsRightJoin()` and `SupportsFullJoin()` must be driver/version gated rather than tied only to the 3.35+ `RETURNING` baseline. See the [SQLite 3.39.0 release notes](https://www.sqlite.org/releaselog/3_39_0.html). +SQLite RIGHT/FULL OUTER JOIN support starts in SQLite 3.39.0. The built-in SQLite dialect targets a 3.35+ baseline and therefore returns false from `SupportsRightJoin()` and `SupportsFullJoin()`; a version-aware custom dialect may return true for engines known to be 3.39+. See the [SQLite 3.39.0 release notes](https://www.sqlite.org/releaselog/3_39_0.html). | Feature | Postgres | MySQL | SQLite | |---|---|---|---| @@ -33,8 +32,8 @@ SQLite RIGHT/FULL OUTER JOIN support starts in SQLite 3.39.0, so Grizzle's SQLit | Recursive CTE helper (`WithRecursive`) | Outside initial target; future GRIZZLE-ONLY helper if specified | Outside initial target; future GRIZZLE-ONLY helper if specified | Outside initial target; future GRIZZLE-ONLY helper if specified | | Window function SQL capability (`OVER`) | Yes | Yes (8.0+) | Yes (3.25+) | | `DISTINCT ON` | Yes | No API or fail-fast | No API or fail-fast | -| `RIGHT JOIN` | Yes | Yes | RC.1 builder-surface parity on capable engines; DEVIATION:INTENTIONAL fail-fast gating requires SQLite 3.39+ or version-gated `SupportsRightJoin()` | -| `FULL JOIN` | Yes | No API or fail-fast | RC.1 builder-surface parity on capable engines; DEVIATION:INTENTIONAL fail-fast gating requires SQLite 3.39+ or version-gated `SupportsFullJoin()` | +| `RIGHT JOIN` | Yes | Yes | Built-in dialect fails fast; version-aware custom dialects may enable it for SQLite 3.39+ | +| `FULL JOIN` | Yes | No API or fail-fast | Built-in dialect fails fast; version-aware custom dialects may enable it for SQLite 3.39+ | | `FOR UPDATE OF` | Accepts active `PGLockTableSource` handles; generated code emits the marker only for PostgreSQL table handles/aliases. Active-membership validation is DEVIATION:INTENTIONAL fail-fast hardening. | No RC.1 API; omit or fail-fast | No API or fail-fast | | `FOR SHARE OF` | Accepts active `PGLockTableSource` handles; generated code emits the marker only for PostgreSQL table handles/aliases. Active-membership validation is DEVIATION:INTENTIONAL fail-fast hardening. | No RC.1 API; omit or fail-fast | No API or fail-fast | | `NOWAIT` / `SKIP LOCKED` | Supported | Supported (8.0+) | No API or fail-fast | @@ -44,8 +43,6 @@ SQLite RIGHT/FULL OUTER JOIN support starts in SQLite 3.39.0, so Grizzle's SQLit ## Using a dialect -Target API note: examples in this section use the error-returning `Build(dialect)` contract. The current branch may still expose the older two-return `Build(dialect)` shape until that target contract lands. - Pass the dialect to `.Build()` on any query builder: ```go @@ -60,7 +57,7 @@ sql, args, err := query.Select(db.UsersT.ID, db.UsersT.Username). ## Dialect interface -This is the target dialect interface for the RC.1-parity work. The current branch may still expose older method names such as `InsertIgnoreClause` and `SupportsForShareOf`; treat those as implementation gaps until the target interface or compatibility shims land. +The shared query builders use this dialect interface. Custom dialects must implement every capability method explicitly so unsupported features fail closed. ```go type UpsertStyle string @@ -75,8 +72,8 @@ type Dialect interface { // Placeholder returns "$n" (Postgres) or "?" (MySQL/SQLite) for the nth argument. Placeholder(n int) string - // QuoteIdent wraps one identifier part in the appropriate quote characters. - QuoteIdent(name string) (string, error) + // QuoteIdent wraps one already-validated identifier part. + QuoteIdent(name string) string // Name returns "postgres", "mysql", or "sqlite". Name() string @@ -88,9 +85,9 @@ type Dialect interface { // UpsertStyle returns the conflict-resolution style. UpsertStyle() UpsertStyle - // Dialect-specific insert-ignore helpers. - MySQLInsertIgnoreKeyword() string - SQLiteOnConflictDoNothingClause() string + // Dialect-specific INSERT keyword for ignore-conflict syntax. + InsertIgnoreClause() string + SupportsIgnoreConflicts() bool // Feature-detection methods — unsupported features are omitted from // dialect-specific builders or returned as Build errors from shared builders. @@ -101,16 +98,15 @@ type Dialect interface { SupportsFullJoin() bool // false for MySQL; SQLite true only for engines known to support RIGHT/FULL JOIN, 3.39+ SupportsForUpdate() bool SupportsForNoKeyUpdate() bool - SupportsForKeyShare() bool - ForShareClause() string // "FOR SHARE" - SupportsLockOf() bool // PostgreSQL-compatible; false for MySQL/SQLite RC.1 + ForShareClause() string + SupportsForShareOf() bool SupportsRegexpMatch() bool // false → omit API or Build returns unsupported_feature SupportsFullTextSearch() bool // false → omit API or Build returns unsupported_feature SupportsLimitOnMutate() bool // SQLite must be driver/compile-option gated } ``` -A dialect is MySQL-compatible for MySQL-only insert builders when `UpsertStyle() == UpsertDuplicateKey` and `MySQLInsertIgnoreKeyword() != ""`. Custom dialects must satisfy both checks rather than relying on `Name()`. +The shared `IgnoreConflicts()` helper is enabled only when `SupportsIgnoreConflicts()` is true. `UpsertStyle()` and `InsertIgnoreClause()` then select the classified syntax without branching on `Name()`. ## Implementing a custom dialect @@ -121,18 +117,13 @@ type CRDBDialect struct{} func (CRDBDialect) Name() string { return "crdb" } func (CRDBDialect) Placeholder(n int) string { return fmt.Sprintf("$%d", n) } -func (CRDBDialect) QuoteIdent(name string) (string, error) { - if strings.Contains(name, ".") || strings.ContainsFunc(name, func(r rune) bool { - return r == 0 || r < 0x20 || r == 0x7f - }) { - return "", fmt.Errorf("identifier is not a valid single identifier part") - } - return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`, nil +func (CRDBDialect) QuoteIdent(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } func (CRDBDialect) SupportsReturning() bool { return true } func (CRDBDialect) UpsertStyle() dialect.UpsertStyle { return dialect.UpsertOnConflict } -func (CRDBDialect) MySQLInsertIgnoreKeyword() string { return "" } -func (CRDBDialect) SQLiteOnConflictDoNothingClause() string { return "" } +func (CRDBDialect) InsertIgnoreClause() string { return "" } +func (CRDBDialect) SupportsIgnoreConflicts() bool { return false } func (CRDBDialect) SupportsCTE() bool { return true } func (CRDBDialect) SupportsWindowFunctions() bool { return true } func (CRDBDialect) SupportsDistinctOn() bool { return true } @@ -140,9 +131,8 @@ func (CRDBDialect) SupportsRightJoin() bool { return true } func (CRDBDialect) SupportsFullJoin() bool { return true } func (CRDBDialect) SupportsForUpdate() bool { return true } func (CRDBDialect) SupportsForNoKeyUpdate() bool { return true } -func (CRDBDialect) SupportsForKeyShare() bool { return true } func (CRDBDialect) ForShareClause() string { return "FOR SHARE" } -func (CRDBDialect) SupportsLockOf() bool { return true } +func (CRDBDialect) SupportsForShareOf() bool { return true } func (CRDBDialect) SupportsRegexpMatch() bool { return true } // CockroachDB supports PG regex syntax func (CRDBDialect) SupportsFullTextSearch() bool { return true } // CockroachDB supports PG FTS func (CRDBDialect) SupportsLimitOnMutate() bool { return false } diff --git a/docs/spec/query-builder.md b/docs/spec/query-builder.md index cccc3b3..781e5db 100644 --- a/docs/spec/query-builder.md +++ b/docs/spec/query-builder.md @@ -379,7 +379,7 @@ Calling `Of(...)` without a lock mode must produce a build validation error, or | `except(q1, q2)` | `query.Except(q1, q2)` | PARITY | | `exceptAll(q1, q2)` (PostgreSQL / MySQL) | `query.ExceptAll(q1, q2)` for PostgreSQL / MySQL | PARITY | | no SQLite `intersectAll` / `exceptAll` exports | no SQLite `IntersectAll` / `ExceptAll` parity surface, or SQLite fast-fail if a shared Go builder exposes them | PARITY for omission; **DEVIATION:LANGUAGE** if exposed only to fail fast | -| `.orderBy()` on set op | `.OrderBy()` | PARITY — Drizzle strips table qualifiers from `PgColumn` refs automatically; Grizzle does the same via `ToSQLUnqualified` | +| `.orderBy()` on set op | `.OrderBy()` | PARITY — Drizzle strips table qualifiers from `PgColumn` refs automatically; Grizzle does the same via `RenderSQLUnqualified` | | `.limit()` on set op | `.Limit()` | PARITY | ### Subqueries @@ -862,7 +862,7 @@ If a shared `.IgnoreConflicts()` helper is retained, its render/error matrix mus - PostgreSQL: render `ON CONFLICT DO NOTHING` with no target - MySQL: render `INSERT IGNORE` - SQLite: render `ON CONFLICT DO NOTHING` with no target, matching RC.1's `onConflictDoNothing()` SQL form rather than broadening to unrelated SQLite conflict algorithms -- unsupported/custom dialects: return a build error rather than silently changing insert behavior +- unsupported/custom dialects whose no-op conflict semantics have not been explicitly classified: return a build error rather than silently changing insert behavior `Ignore()`, `DoNothing`, and optional `IgnoreConflicts()` can hide data-quality or integrity failures. MySQL `INSERT IGNORE` is especially broad because the database may downgrade additional constraint and data errors to warnings. Callers should observe row counts, warnings where the driver exposes them, or application-level reconciliation when skipped rows matter. diff --git a/driver/pgx/db.go b/driver/pgx/db.go index 34a4972..9bf3883 100644 --- a/driver/pgx/db.go +++ b/driver/pgx/db.go @@ -8,7 +8,7 @@ // db := pgxdb.New(pool) // // // Build a query with the query package, execute with pgx. -// sql, args := query.Select(UsersT.ID, UsersT.Name). +// sql, args, err := query.Select(UsersT.ID, UsersT.Name). // From(UsersT). // Where(UsersT.DeletedAt.IsNull()). // Build(dialect.Postgres) @@ -20,6 +20,7 @@ package pgx import ( "context" "fmt" + "reflect" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -50,19 +51,39 @@ func (db *DB) Dialect() dialect.Dialect { return dialect.Postgres } // Query executes a SELECT builder and returns the raw pgx.Rows. // Use ScanAll or ScanOne to collect results into typed structs. -func (db *DB) Query(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (pgx.Rows, error) { - sql, args := b.Build(dialect.Postgres) +func (db *DB) Query(ctx context.Context, b query.Builder) (pgx.Rows, error) { + if db == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "pgx_query", "database receiver is invalid") + } + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "pgx_query", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if db.pool == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "pgx_query", "database receiver is invalid") + } return db.pool.Query(ctx, sql, args...) } // Exec executes an INSERT, UPDATE, or DELETE builder and returns the // number of rows affected. -func (db *DB) Exec(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (int64, error) { - sql, args := b.Build(dialect.Postgres) +func (db *DB) Exec(ctx context.Context, b query.Builder) (int64, error) { + if db == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "pgx_exec", "database receiver is invalid") + } + if isNilValue(b) { + return 0, query.NewError(query.CodeBuildValidation, "pgx_exec", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return 0, err + } + if db.pool == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "pgx_exec", "database receiver is invalid") + } tag, err := db.pool.Exec(ctx, sql, args...) if err != nil { return 0, err @@ -174,18 +195,38 @@ func (db *DB) Transaction(ctx context.Context, fn func(tx *Tx) error) error { } // Query executes a SELECT builder within the transaction. -func (tx *Tx) Query(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (pgx.Rows, error) { - sql, args := b.Build(dialect.Postgres) +func (tx *Tx) Query(ctx context.Context, b query.Builder) (pgx.Rows, error) { + if tx == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "pgx_tx_query", "transaction receiver is invalid") + } + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "pgx_tx_query", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if isNilValue(tx.tx) { + return nil, query.NewError(query.CodeInvalidReceiver, "pgx_tx_query", "transaction receiver is invalid") + } return tx.tx.Query(ctx, sql, args...) } // Exec executes an INSERT/UPDATE/DELETE builder within the transaction. -func (tx *Tx) Exec(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (int64, error) { - sql, args := b.Build(dialect.Postgres) +func (tx *Tx) Exec(ctx context.Context, b query.Builder) (int64, error) { + if tx == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "pgx_tx_exec", "transaction receiver is invalid") + } + if isNilValue(b) { + return 0, query.NewError(query.CodeBuildValidation, "pgx_tx_exec", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return 0, err + } + if isNilValue(tx.tx) { + return 0, query.NewError(query.CodeInvalidReceiver, "pgx_tx_exec", "transaction receiver is invalid") + } tag, err := tx.tx.Exec(ctx, sql, args...) if err != nil { return 0, err @@ -234,3 +275,16 @@ func FromSelectOpt[T any](ctx context.Context, db *DB, b *query.SelectBuilder) ( rows, err := db.Query(ctx, b) return ScanOneOpt[T](rows, err) } + +func isNilValue(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/driver/pgx/prepared.go b/driver/pgx/prepared.go index 2a8312a..3d5a12a 100644 --- a/driver/pgx/prepared.go +++ b/driver/pgx/prepared.go @@ -2,7 +2,7 @@ package pgx import ( "context" - "fmt" + "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -79,7 +79,16 @@ type PreparedSelect[T any] struct { // a PreparedSelect for repeated execution. Returns an error if the SQL is // syntactically invalid or references unknown columns or tables. func PrepareSelect[T any](ctx context.Context, db *DB, name string, b *query.SelectBuilder) (*PreparedSelect[T], error) { - sql, args := b.Build(dialect.Postgres) + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "prepare_select", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if db == nil || db.pool == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "prepare_select", "database receiver is invalid") + } if err := validateStatement(ctx, db, name, sql); err != nil { return nil, err } @@ -95,17 +104,27 @@ func (p *PreparedSelect[T]) SQL() string { return p.sql } // QueryAll executes the prepared query and returns all matching rows. func (p *PreparedSelect[T]) QueryAll(ctx context.Context, db *DB) ([]T, error) { + if p == nil || db == nil || db.pool == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "prepared_select_query_all", "prepared query receiver is invalid") + } return p.queryAllWith(ctx, db.Pool()) } // QueryOne executes the prepared query and expects exactly one row. // Returns an error if zero or more than one row is returned. func (p *PreparedSelect[T]) QueryOne(ctx context.Context, db *DB) (T, error) { + var zero T + if p == nil || db == nil || db.pool == nil { + return zero, query.NewError(query.CodeInvalidReceiver, "prepared_select_query_one", "prepared query receiver is invalid") + } return p.queryOneWith(ctx, db.Pool()) } // QueryOpt executes the prepared query and returns nil if no rows are found. func (p *PreparedSelect[T]) QueryOpt(ctx context.Context, db *DB) (*T, error) { + if p == nil || db == nil || db.pool == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "prepared_select_query_opt", "prepared query receiver is invalid") + } return p.queryOptWith(ctx, db.Pool()) } @@ -160,10 +179,17 @@ type PreparedExec struct { // PrepareExec validates and caches a mutation query. The builder interface // accepts SelectBuilder, InsertBuilder, UpdateBuilder, or DeleteBuilder — // anything with a Build method. -func PrepareExec(ctx context.Context, db *DB, name string, b interface { - Build(dialect.Dialect) (string, []any) -}) (*PreparedExec, error) { - sql, args := b.Build(dialect.Postgres) +func PrepareExec(ctx context.Context, db *DB, name string, b query.Builder) (*PreparedExec, error) { + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "prepare_exec", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if db == nil || db.pool == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "prepare_exec", "database receiver is invalid") + } if err := validateStatement(ctx, db, name, sql); err != nil { return nil, err } @@ -179,11 +205,17 @@ func (p *PreparedExec) SQL() string { return p.sql } // Exec runs the prepared mutation and returns the number of rows affected. func (p *PreparedExec) Exec(ctx context.Context, db *DB) (int64, error) { + if p == nil || db == nil || db.pool == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "prepared_exec", "prepared query receiver is invalid") + } return p.execWith(ctx, db.Pool()) } // ExecTx runs the prepared mutation inside an existing transaction. func (p *PreparedExec) ExecTx(ctx context.Context, tx *Tx) (int64, error) { + if p == nil || tx == nil || isNilValue(tx.tx) { + return 0, query.NewError(query.CodeInvalidReceiver, "prepared_exec_tx", "prepared query receiver is invalid") + } return p.execWith(ctx, tx.tx) } @@ -208,8 +240,8 @@ func (p *PreparedExec) execWith(ctx context.Context, e poolExecer) (int64, error // Example: // // reg := pgxdb.NewRegistry(db) -// getUser := pgxdb.Register[UserSelect](reg, "get_active_users", activeUsersQuery) -// updateUser := pgxdb.RegisterExec(reg, "soft_delete", softDeleteQuery) +// getUser, err := pgxdb.RegisterSelect[UserSelect](reg, "get_active_users", activeUsersQuery) +// updateUser, err := pgxdb.RegisterExec(reg, "soft_delete", softDeleteQuery) // // if err := reg.PrepareAll(ctx); err != nil { // log.Fatal("query validation failed:", err) @@ -234,9 +266,12 @@ func NewRegistry(db *DB) *Registry { // Call this once during server startup; if it returns an error, at least one // query has a SQL problem. func (r *Registry) PrepareAll(ctx context.Context) error { + if r == nil || r.db == nil || r.db.pool == nil { + return query.NewError(query.CodeInvalidReceiver, "prepare_registry", "registry receiver is invalid") + } for _, e := range r.entries { if err := validateStatement(ctx, r.db, e.name, e.sql); err != nil { - return fmt.Errorf("prepare %q: %w", e.name, err) + return err } } return nil @@ -251,22 +286,38 @@ func (r *Registry) register(name string, sql string, args []any) { // to validate all registered statements in one shot. // // reg := pgxdb.NewRegistry(db) -// stmt := pgxdb.RegisterSelect[UserSelect](reg, "active_users", activeUsersBuilder) +// stmt, err := pgxdb.RegisterSelect[UserSelect](reg, "active_users", activeUsersBuilder) // if err := reg.PrepareAll(ctx); err != nil { ... } // users, err := stmt.QueryAll(ctx, db) -func RegisterSelect[T any](reg *Registry, name string, b *query.SelectBuilder) *PreparedSelect[T] { - sql, args := b.Build(dialect.Postgres) +func RegisterSelect[T any](reg *Registry, name string, b *query.SelectBuilder) (*PreparedSelect[T], error) { + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "register_select", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if reg == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "register_select", "registry receiver is invalid") + } reg.register(name, sql, args) - return &PreparedSelect[T]{name: name, sql: sql, args: args} + return &PreparedSelect[T]{name: name, sql: sql, args: args}, nil } // RegisterExec adds a mutation query to a Registry. -func RegisterExec(reg *Registry, name string, b interface { - Build(dialect.Dialect) (string, []any) -}) *PreparedExec { - sql, args := b.Build(dialect.Postgres) +func RegisterExec(reg *Registry, name string, b query.Builder) (*PreparedExec, error) { + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "register_exec", "query builder is nil") + } + sql, args, err := b.Build(dialect.Postgres) + if err != nil { + return nil, err + } + if reg == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "register_exec", "registry receiver is invalid") + } reg.register(name, sql, args) - return &PreparedExec{name: name, sql: sql, args: args} + return &PreparedExec{name: name, sql: sql, args: args}, nil } // ------------------------------------------------------------------- @@ -283,16 +334,27 @@ func RegisterExec(reg *Registry, name string, b interface { // validates the SQL without executing it — wrong column names, type errors, and // syntax problems are all caught here. func validateStatement(ctx context.Context, db *DB, name, sql string) error { + if db == nil || db.pool == nil { + return query.NewError(query.CodeInvalidReceiver, "validate_statement", "database receiver is invalid") + } conn, err := db.Pool().Acquire(ctx) if err != nil { - return fmt.Errorf("acquire connection: %w", err) + return preparedValidationError("acquire_prepared_connection", err) } defer conn.Release() // Conn() returns the underlying *pgx.Conn. // Prepare(ctx, name, sql) sends a Parse + Describe to the backend. if _, err := conn.Conn().Prepare(ctx, name, sql); err != nil { - return fmt.Errorf("SQL validation failed for %q: %w", name, err) + return preparedValidationError("validate_prepared_statement", err) } return nil } + +func preparedValidationError(op string, cause error) *query.Error { + err := query.NewError(query.CodePreparedNotReady, op, "prepared statement validation failed") + if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { + err.Err = cause + } + return err +} diff --git a/driver/pgx/prepared_execution_test.go b/driver/pgx/prepared_execution_test.go index 531c8b7..0e27a8d 100644 --- a/driver/pgx/prepared_execution_test.go +++ b/driver/pgx/prepared_execution_test.go @@ -9,17 +9,113 @@ package pgx import ( "context" + "errors" "reflect" + "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/sofired/grizzle/dialect" + "github.com/sofired/grizzle/expr" "github.com/sofired/grizzle/internal/testschema" "github.com/sofired/grizzle/query" ) +func invalidSelectBuilder() *query.SelectBuilder { + return query.Select().Where(expr.RawArgs("x = $? AND y = $?", 1)) +} + +type typedNilBuilder struct{} + +func (*typedNilBuilder) Build(dialect.Dialect) (string, []any, error) { + panic("typed-nil builder must be rejected before Build") +} + +func TestPreparedHelpers_PropagateBuildErrorsBeforeDatabaseUse(t *testing.T) { + ctx := context.Background() + + if stmt, err := PrepareSelect[any](ctx, nil, "invalid", invalidSelectBuilder()); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("PrepareSelect = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + if stmt, err := PrepareExec(ctx, nil, "invalid", invalidSelectBuilder()); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("PrepareExec = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + + reg := NewRegistry(nil) + if stmt, err := RegisterSelect[any](reg, "invalid", invalidSelectBuilder()); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("RegisterSelect = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + if stmt, err := RegisterExec(reg, "invalid", invalidSelectBuilder()); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("RegisterExec = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + if len(reg.entries) != 0 { + t.Fatalf("failed registrations mutated registry: %v", reg.entries) + } +} + +func TestExecutionHelpers_PropagateBuildErrorsBeforePoolUse(t *testing.T) { + ctx := context.Background() + db := New(nil) + if rows, err := db.Query(ctx, invalidSelectBuilder()); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Query = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := db.Exec(ctx, invalidSelectBuilder()); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Exec = (%d, %v), want zero and ErrBuildValidation", affected, err) + } + + tx := &Tx{} + if rows, err := tx.Query(ctx, invalidSelectBuilder()); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Tx.Query = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := tx.Exec(ctx, invalidSelectBuilder()); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Tx.Exec = (%d, %v), want zero and ErrBuildValidation", affected, err) + } +} + +func TestExecutionHelpers_RejectTypedNilBuildersAndReceivers(t *testing.T) { + ctx := context.Background() + var b *typedNilBuilder + db := New(nil) + if rows, err := db.Query(ctx, b); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Query typed nil = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := db.Exec(ctx, b); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Exec typed nil = (%d, %v), want zero and ErrBuildValidation", affected, err) + } + if stmt, err := PrepareExec(ctx, nil, "typed_nil", b); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("PrepareExec typed nil = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + if stmt, err := RegisterExec(NewRegistry(nil), "typed_nil", b); stmt != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("RegisterExec typed nil = (%v, %v), want nil and ErrBuildValidation", stmt, err) + } + + var nilDB *DB + if rows, err := nilDB.Query(ctx, query.Select()); rows != nil || !errors.Is(err, query.ErrInvalidReceiver) { + t.Fatalf("nil DB Query = (%v, %v), want nil and ErrInvalidReceiver", rows, err) + } + var nilTx *Tx + if affected, err := nilTx.Exec(ctx, query.Select()); affected != 0 || !errors.Is(err, query.ErrInvalidReceiver) { + t.Fatalf("nil Tx Exec = (%d, %v), want zero and ErrInvalidReceiver", affected, err) + } +} + +func TestPreparedValidationErrorIsRedacted(t *testing.T) { + err := preparedValidationError("validate_prepared_statement", errors.New("secret statement and SQL")) + if !errors.Is(err, query.ErrPreparedNotReady) { + t.Fatalf("error = %v, want ErrPreparedNotReady", err) + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "SQL") { + t.Fatalf("error leaked driver detail: %q", err) + } + canceled := preparedValidationError("validate_prepared_statement", context.Canceled) + if !errors.Is(canceled, query.ErrPreparedNotReady) || !errors.Is(canceled, context.Canceled) { + t.Fatalf("canceled error lost stable or context sentinel: %v", canceled) + } +} + // stubQuerier is a poolQuerier stub that records the SQL string and args // passed to Query and returns pgx.ErrNoRows. Tests verify both the SQL string // and that args pass through unchanged. @@ -56,7 +152,7 @@ func TestPreparedSelect_QueryAllUsesSQLNotName(t *testing.T) { From(testschema.UsersT). Where(testschema.UsersT.RealmID.EQ(realmID)) reg := NewRegistry(nil) - stmt := RegisterSelect[testschema.UserSelect](reg, "active_users", b) + stmt, _ := RegisterSelect[testschema.UserSelect](reg, "active_users", b) stub := &stubQuerier{} // queryAllWith returns pgx.ErrNoRows from the stub — ignore the scan error. @@ -81,7 +177,7 @@ func TestPreparedSelect_QueryOneUsesSQLNotName(t *testing.T) { From(testschema.UsersT). Where(testschema.UsersT.RealmID.EQ(realmID)) reg := NewRegistry(nil) - stmt := RegisterSelect[testschema.UserSelect](reg, "active_users", b) + stmt, _ := RegisterSelect[testschema.UserSelect](reg, "active_users", b) stub := &stubQuerier{} _, _ = stmt.queryOneWith(context.Background(), stub) @@ -105,7 +201,7 @@ func TestPreparedSelect_QueryOptUsesSQLNotName(t *testing.T) { From(testschema.UsersT). Where(testschema.UsersT.RealmID.EQ(realmID)) reg := NewRegistry(nil) - stmt := RegisterSelect[testschema.UserSelect](reg, "active_users", b) + stmt, _ := RegisterSelect[testschema.UserSelect](reg, "active_users", b) stub := &stubQuerier{} _, _ = stmt.queryOptWith(context.Background(), stub) @@ -131,7 +227,7 @@ func TestPreparedExec_ExecUsesSQLNotName(t *testing.T) { Where(testschema.UsersT.RealmID.EQ(realmID)) reg := NewRegistry(nil) - stmt := RegisterExec(reg, "disable_users", b) + stmt, _ := RegisterExec(reg, "disable_users", b) stub := &stubExecer{} _, err := stmt.execWith(context.Background(), stub) @@ -189,7 +285,7 @@ func TestPreparedExec_ExecTxUsesSQLNotName(t *testing.T) { Where(testschema.UsersT.RealmID.EQ(realmID)) reg := NewRegistry(nil) - stmt := RegisterExec(reg, "disable_users", b) + stmt, _ := RegisterExec(reg, "disable_users", b) fake := &fakePgxTx{} tx := &Tx{tx: fake} diff --git a/driver/pgx/prepared_test.go b/driver/pgx/prepared_test.go index 6fa9245..36528dd 100644 --- a/driver/pgx/prepared_test.go +++ b/driver/pgx/prepared_test.go @@ -23,7 +23,7 @@ func TestPreparedSelect_SQLBuiltOnce(t *testing.T) { // NewRegistry(nil) is safe as long as PrepareAll is not called. reg := pgxdb.NewRegistry(nil) - stmt := pgxdb.RegisterSelect[testschema.UserSelect](reg, "active_users", b) + stmt, _ := pgxdb.RegisterSelect[testschema.UserSelect](reg, "active_users", b) if stmt.Name() != "active_users" { t.Errorf("Name() = %q, want %q", stmt.Name(), "active_users") @@ -55,7 +55,7 @@ func TestPreparedExec_SQLBuiltOnce(t *testing.T) { Where(testschema.UsersT.ID.EQ(id)) reg := pgxdb.NewRegistry(nil) - stmt := pgxdb.RegisterExec(reg, "disable_user", b) + stmt, _ := pgxdb.RegisterExec(reg, "disable_user", b) if stmt.Name() != "disable_user" { t.Errorf("Name() = %q, want %q", stmt.Name(), "disable_user") @@ -85,7 +85,7 @@ func TestPreparedSelect_SQLNotName(t *testing.T) { b := query.Select(testschema.UsersT.ID).From(testschema.UsersT) reg := pgxdb.NewRegistry(nil) - stmt := pgxdb.RegisterSelect[testschema.UserSelect](reg, "active_users", b) + stmt, _ := pgxdb.RegisterSelect[testschema.UserSelect](reg, "active_users", b) if stmt.SQL() == stmt.Name() { t.Errorf("SQL() must not equal Name(): got %q for both; "+ @@ -104,7 +104,7 @@ func TestPreparedExec_SQLNotName(t *testing.T) { Where(testschema.UsersT.ID.EQ(id)) reg := pgxdb.NewRegistry(nil) - stmt := pgxdb.RegisterExec(reg, "disable_user", b) + stmt, _ := pgxdb.RegisterExec(reg, "disable_user", b) if stmt.SQL() == stmt.Name() { t.Errorf("SQL() must not equal Name(): got %q for both; "+ @@ -118,10 +118,10 @@ func TestPreparedExec_SQLNotName(t *testing.T) { func TestRegistry_MultipleStatements(t *testing.T) { reg := pgxdb.NewRegistry(nil) - s1 := pgxdb.RegisterSelect[testschema.UserSelect](reg, "all_users", + s1, _ := pgxdb.RegisterSelect[testschema.UserSelect](reg, "all_users", query.Select(testschema.UsersT.ID).From(testschema.UsersT)) - s2 := pgxdb.RegisterSelect[testschema.RealmSelect](reg, "all_realms", + s2, _ := pgxdb.RegisterSelect[testschema.RealmSelect](reg, "all_realms", query.Select(testschema.RealmsT.ID).From(testschema.RealmsT)) if s1.Name() != "all_users" { diff --git a/driver/sql/db.go b/driver/sql/db.go index 2e79ae4..e7a70a7 100644 --- a/driver/sql/db.go +++ b/driver/sql/db.go @@ -16,7 +16,7 @@ // raw, err := sql.Open("mysql", dsn) // db := sqldb.New(raw, dialect.MySQL) // -// sql, args := query.Select(UsersT.ID, UsersT.Name). +// sql, args, err := query.Select(UsersT.ID, UsersT.Name). // From(UsersT). // Where(UsersT.DeletedAt.IsNull()). // Build(db.Dialect()) @@ -76,19 +76,39 @@ func (w *DB) Dialect() dialect.Dialect { return w.d } // Query executes a SELECT builder and returns the raw *sql.Rows. // Use ScanAll or ScanOne to collect results into typed structs. -func (w *DB) Query(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (*sql.Rows, error) { - q, args := b.Build(w.d) +func (w *DB) Query(ctx context.Context, b query.Builder) (*sql.Rows, error) { + if w == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "sql_query", "database receiver is invalid") + } + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "sql_query", "query builder is nil") + } + q, args, err := b.Build(w.d) + if err != nil { + return nil, err + } + if w.db == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "sql_query", "database receiver is invalid") + } return w.db.QueryContext(ctx, q, args...) } // Exec executes an INSERT, UPDATE, or DELETE builder and returns the number // of rows affected. -func (w *DB) Exec(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (int64, error) { - q, args := b.Build(w.d) +func (w *DB) Exec(ctx context.Context, b query.Builder) (int64, error) { + if w == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "sql_exec", "database receiver is invalid") + } + if isNilValue(b) { + return 0, query.NewError(query.CodeBuildValidation, "sql_exec", "query builder is nil") + } + q, args, err := b.Build(w.d) + if err != nil { + return 0, err + } + if w.db == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "sql_exec", "database receiver is invalid") + } res, err := w.db.ExecContext(ctx, q, args...) if err != nil { return 0, err @@ -238,18 +258,38 @@ func (w *DB) Transaction(ctx context.Context, fn func(tx *Tx) error) error { func (tx *Tx) Dialect() dialect.Dialect { return tx.d } // Query executes a SELECT builder within the transaction. -func (tx *Tx) Query(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (*sql.Rows, error) { - q, args := b.Build(tx.d) +func (tx *Tx) Query(ctx context.Context, b query.Builder) (*sql.Rows, error) { + if tx == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "sql_tx_query", "transaction receiver is invalid") + } + if isNilValue(b) { + return nil, query.NewError(query.CodeBuildValidation, "sql_tx_query", "query builder is nil") + } + q, args, err := b.Build(tx.d) + if err != nil { + return nil, err + } + if tx.tx == nil { + return nil, query.NewError(query.CodeInvalidReceiver, "sql_tx_query", "transaction receiver is invalid") + } return tx.tx.QueryContext(ctx, q, args...) } // Exec executes an INSERT/UPDATE/DELETE builder within the transaction. -func (tx *Tx) Exec(ctx context.Context, b interface { - Build(dialect.Dialect) (string, []any) -}) (int64, error) { - q, args := b.Build(tx.d) +func (tx *Tx) Exec(ctx context.Context, b query.Builder) (int64, error) { + if tx == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "sql_tx_exec", "transaction receiver is invalid") + } + if isNilValue(b) { + return 0, query.NewError(query.CodeBuildValidation, "sql_tx_exec", "query builder is nil") + } + q, args, err := b.Build(tx.d) + if err != nil { + return 0, err + } + if tx.tx == nil { + return 0, query.NewError(query.CodeInvalidReceiver, "sql_tx_exec", "transaction receiver is invalid") + } res, err := tx.tx.ExecContext(ctx, q, args...) if err != nil { return 0, err @@ -296,6 +336,19 @@ func FromSelectOpt[T any](ctx context.Context, db *DB, b *query.SelectBuilder) ( return ScanOneOpt[T](rows, err) } +func isNilValue(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + default: + return false + } +} + // ------------------------------------------------------------------- // Internal reflection-based row scanner // ------------------------------------------------------------------- diff --git a/driver/sql/db_test.go b/driver/sql/db_test.go index 37f419b..06fdd00 100644 --- a/driver/sql/db_test.go +++ b/driver/sql/db_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" gosql "database/sql" + "errors" "testing" _ "github.com/mattn/go-sqlite3" @@ -13,6 +14,62 @@ import ( "github.com/sofired/grizzle/query" ) +type typedNilBuilder struct{} + +func (*typedNilBuilder) Build(dialect.Dialect) (string, []any, error) { + panic("typed-nil builder must be rejected before Build") +} + +func TestExecutionHelpers_PropagateBuildErrorsBeforeDatabaseUse(t *testing.T) { + db := sqldb.New(nil, dialect.Postgres) + b := query.Select().Where(expr.RawArgs("x = $? AND y = $?", 1)) + if rows, err := db.Query(context.Background(), b); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Query = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := db.Exec(context.Background(), b); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Exec = (%d, %v), want zero and ErrBuildValidation", affected, err) + } +} + +func TestExecutionHelpers_RejectTypedNilBuildersAndReceivers(t *testing.T) { + ctx := context.Background() + var b *typedNilBuilder + db := sqldb.New(nil, dialect.Postgres) + if rows, err := db.Query(ctx, b); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Query typed nil = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := db.Exec(ctx, b); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Exec typed nil = (%d, %v), want zero and ErrBuildValidation", affected, err) + } + + var nilDB *sqldb.DB + if rows, err := nilDB.Query(ctx, query.Select()); rows != nil || !errors.Is(err, query.ErrInvalidReceiver) { + t.Fatalf("nil DB Query = (%v, %v), want nil and ErrInvalidReceiver", rows, err) + } + var nilTx *sqldb.Tx + if affected, err := nilTx.Exec(ctx, query.Select()); affected != 0 || !errors.Is(err, query.ErrInvalidReceiver) { + t.Fatalf("nil Tx Exec = (%d, %v), want zero and ErrInvalidReceiver", affected, err) + } +} + +func TestTransactionExecutionHelpers_PropagateBuildErrorsBeforeDatabaseUse(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + b := query.Select().Where(expr.RawArgs("x = $? AND y = $?", 1)) + err := db.Transaction(ctx, func(tx *sqldb.Tx) error { + if rows, err := tx.Query(ctx, b); rows != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Tx.Query = (%v, %v), want nil and ErrBuildValidation", rows, err) + } + if affected, err := tx.Exec(ctx, b); affected != 0 || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("Tx.Exec = (%d, %v), want zero and ErrBuildValidation", affected, err) + } + return nil + }) + if err != nil { + t.Fatalf("Transaction = %v", err) + } +} + // ------------------------------------------------------------------- // Schema helpers for tests // ------------------------------------------------------------------- diff --git a/expr/agg.go b/expr/agg.go index b3495c6..30cde11 100644 --- a/expr/agg.go +++ b/expr/agg.go @@ -14,34 +14,51 @@ import "strings" // Having(expr.Count().GT(5)). // OrderBy(expr.Count().Desc()) type AggExpr struct { - fn string // "COUNT", "SUM", "AVG", "MAX", "MIN" - col colRefer // nil means COUNT(*) - distinct bool - alias string // optional AS alias (for SELECT only) + fn string // "COUNT", "SUM", "AVG", "MAX", "MIN" + col colRefer // nil means COUNT(*) + distinct bool + alias string // optional AS alias (for SELECT only) + requireCol bool } -// ToSQL renders the aggregate function call, including AS alias when set. +// RenderSQL renders the aggregate function call, including AS alias when set. // This is the form used in SELECT lists. For HAVING/ORDER BY, create the // aggregate without As() so no alias is emitted. -func (a AggExpr) ToSQL(ctx *BuildContext) string { - var arg string - if a.col == nil { - arg = "*" - } else { - arg = a.col.colRef(ctx) - } - if a.distinct { - arg = "DISTINCT " + arg - } - result := a.fn + "(" + arg + ")" - if a.alias != "" { - result += " AS " + ctx.Quote(a.alias) - } - return result +func (a AggExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + if a.fn == "" { + return "", NewError(CodeBuildValidation, "render_aggregate", "aggregate function is empty") + } + var arg string + if isNilInterface(a.col) { + if a.requireCol { + return "", NewError(CodeBuildValidation, "render_aggregate", "aggregate column is nil") + } + arg = "*" + } else { + var err error + arg, err = a.col.colRef(ctx) + if err != nil { + return "", err + } + } + if a.distinct { + arg = "DISTINCT " + arg + } + result := a.fn + "(" + arg + ")" + if a.alias != "" { + alias, err := ctx.Quote(a.alias) + if err != nil { + return "", err + } + result += " AS " + alias + } + return result, nil + }) } // colRef implements colRefer so AggExpr can be embedded in OrderExpr. -func (a AggExpr) colRef(ctx *BuildContext) string { return a.ToSQL(ctx) } +func (a AggExpr) colRef(ctx *BuildContext) (string, error) { return a.RenderSQL(ctx) } // ColumnName implements SelectableColumn. Returns the alias if set, otherwise // the lower-case function name. @@ -86,21 +103,23 @@ func (a AggExpr) NEQ(val any) Expression { return binaryExpr{ref: a, op: "<>", v func Count() AggExpr { return AggExpr{fn: "COUNT"} } // CountCol returns COUNT(col). -func CountCol(col SelectableColumn) AggExpr { return AggExpr{fn: "COUNT", col: col} } +func CountCol(col SelectableColumn) AggExpr { + return AggExpr{fn: "COUNT", col: col, requireCol: true} +} // CountDistinct returns COUNT(DISTINCT col). func CountDistinct(col SelectableColumn) AggExpr { - return AggExpr{fn: "COUNT", col: col, distinct: true} + return AggExpr{fn: "COUNT", col: col, distinct: true, requireCol: true} } // Sum returns SUM(col). -func Sum(col SelectableColumn) AggExpr { return AggExpr{fn: "SUM", col: col} } +func Sum(col SelectableColumn) AggExpr { return AggExpr{fn: "SUM", col: col, requireCol: true} } // Avg returns AVG(col). -func Avg(col SelectableColumn) AggExpr { return AggExpr{fn: "AVG", col: col} } +func Avg(col SelectableColumn) AggExpr { return AggExpr{fn: "AVG", col: col, requireCol: true} } // Max returns MAX(col). -func Max(col SelectableColumn) AggExpr { return AggExpr{fn: "MAX", col: col} } +func Max(col SelectableColumn) AggExpr { return AggExpr{fn: "MAX", col: col, requireCol: true} } // Min returns MIN(col). -func Min(col SelectableColumn) AggExpr { return AggExpr{fn: "MIN", col: col} } +func Min(col SelectableColumn) AggExpr { return AggExpr{fn: "MIN", col: col, requireCol: true} } diff --git a/expr/case.go b/expr/case.go index dbba24b..db6377e 100644 --- a/expr/case.go +++ b/expr/case.go @@ -55,30 +55,60 @@ func (c *CaseExpr) As(alias string) *CaseExpr { return &cp } -// ToSQL renders the CASE expression. -func (c *CaseExpr) ToSQL(ctx *BuildContext) string { - var sb strings.Builder - sb.WriteString("CASE") - for _, w := range c.whens { - sb.WriteString(" WHEN ") - sb.WriteString(w.cond.ToSQL(ctx)) - sb.WriteString(" THEN ") - sb.WriteString(w.then.ToSQL(ctx)) - } - if c.else_ != nil { - sb.WriteString(" ELSE ") - sb.WriteString(c.else_.ToSQL(ctx)) - } - sb.WriteString(" END") - if c.alias != "" { - sb.WriteString(" AS ") - sb.WriteString(ctx.Quote(c.alias)) - } - return sb.String() +// RenderSQL renders the CASE expression. +func (c *CaseExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + if c == nil { + return "", NewError(CodeBuildValidation, "render_case", "case expression is nil") + } + if len(c.whens) == 0 { + return "", NewError(CodeBuildValidation, "render_case", "case expression contains no branches") + } + var sb strings.Builder + sb.WriteString("CASE") + for _, w := range c.whens { + if isNilExpression(w.cond) || isNilExpression(w.then) { + return "", NewError(CodeBuildValidation, "render_case", "case branch contains a nil expression") + } + sb.WriteString(" WHEN ") + cond, err := w.cond.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(cond) + sb.WriteString(" THEN ") + then, err := w.then.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(then) + } + if c.else_ != nil { + if isNilExpression(c.else_) { + return "", NewError(CodeBuildValidation, "render_case", "case fallback is typed nil") + } + sb.WriteString(" ELSE ") + fallback, err := c.else_.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(fallback) + } + sb.WriteString(" END") + if c.alias != "" { + sb.WriteString(" AS ") + alias, err := ctx.Quote(c.alias) + if err != nil { + return "", err + } + sb.WriteString(alias) + } + return sb.String(), nil + }) } // colRef implements colRefer so CaseExpr can appear in OrderExpr and binary expressions. -func (c *CaseExpr) colRef(ctx *BuildContext) string { return c.ToSQL(ctx) } +func (c *CaseExpr) colRef(ctx *BuildContext) (string, error) { return c.RenderSQL(ctx) } // ColumnName implements SelectableColumn. Returns the alias if set, otherwise "case". func (c *CaseExpr) ColumnName() string { @@ -153,30 +183,60 @@ func (c *SimpleCaseExpr) As(alias string) *SimpleCaseExpr { return &cp } -// ToSQL renders the simple CASE expression. -func (c *SimpleCaseExpr) ToSQL(ctx *BuildContext) string { - var sb strings.Builder - sb.WriteString("CASE ") - sb.WriteString(c.subject.colRef(ctx)) - for _, w := range c.whens { - sb.WriteString(" WHEN ") - sb.WriteString(ctx.Add(w.val)) - sb.WriteString(" THEN ") - sb.WriteString(w.then.ToSQL(ctx)) - } - if c.else_ != nil { - sb.WriteString(" ELSE ") - sb.WriteString(c.else_.ToSQL(ctx)) - } - sb.WriteString(" END") - if c.alias != "" { - sb.WriteString(" AS ") - sb.WriteString(ctx.Quote(c.alias)) - } - return sb.String() +// RenderSQL renders the simple CASE expression. +func (c *SimpleCaseExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + if c == nil || isNilInterface(c.subject) { + return "", NewError(CodeBuildValidation, "render_simple_case", "case subject is nil") + } + if len(c.whens) == 0 { + return "", NewError(CodeBuildValidation, "render_simple_case", "case expression contains no branches") + } + var sb strings.Builder + sb.WriteString("CASE ") + subject, err := c.subject.colRef(ctx) + if err != nil { + return "", err + } + sb.WriteString(subject) + for _, w := range c.whens { + if isNilExpression(w.then) { + return "", NewError(CodeBuildValidation, "render_simple_case", "case branch contains a nil expression") + } + sb.WriteString(" WHEN ") + sb.WriteString(ctx.Add(w.val)) + sb.WriteString(" THEN ") + then, err := w.then.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(then) + } + if c.else_ != nil { + if isNilExpression(c.else_) { + return "", NewError(CodeBuildValidation, "render_simple_case", "case fallback is typed nil") + } + sb.WriteString(" ELSE ") + fallback, err := c.else_.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(fallback) + } + sb.WriteString(" END") + if c.alias != "" { + sb.WriteString(" AS ") + alias, err := ctx.Quote(c.alias) + if err != nil { + return "", err + } + sb.WriteString(alias) + } + return sb.String(), nil + }) } -func (c *SimpleCaseExpr) colRef(ctx *BuildContext) string { return c.ToSQL(ctx) } +func (c *SimpleCaseExpr) colRef(ctx *BuildContext) (string, error) { return c.RenderSQL(ctx) } func (c *SimpleCaseExpr) ColumnName() string { if c.alias != "" { return c.alias @@ -201,4 +261,4 @@ func Lit(v any) Expression { return litExpr{v: v} } type litExpr struct{ v any } -func (e litExpr) ToSQL(ctx *BuildContext) string { return ctx.Add(e.v) } +func (e litExpr) RenderSQL(ctx *BuildContext) (string, error) { return ctx.Add(e.v), nil } diff --git a/expr/columns.go b/expr/columns.go index f603c33..4846ca6 100644 --- a/expr/columns.go +++ b/expr/columns.go @@ -18,7 +18,7 @@ type ColBase struct { ColName string // the SQL column name } -func (c ColBase) colRef(ctx *BuildContext) string { +func (c ColBase) colRef(ctx *BuildContext) (string, error) { return ctx.ColRef(c.TableAlias, c.ColName) } @@ -63,30 +63,43 @@ type OrderExpr struct { nulls string // "NULLS FIRST", "NULLS LAST", or "" } -func (o OrderExpr) ToSQL(ctx *BuildContext) string { - s := o.ref.colRef(ctx) + " " + o.dir +func (o OrderExpr) RenderSQL(ctx *BuildContext) (string, error) { + if isNilInterface(o.ref) { + return "", NewError(CodeBuildValidation, "render_order", "order expression is empty") + } + ref, err := o.ref.colRef(ctx) + if err != nil { + return "", err + } + s := ref + " " + o.dir if o.nulls != "" { s += " " + o.nulls } - return s + return s, nil } -// ToSQLUnqualified renders the ORDER BY expression using only the column name, +// RenderSQLUnqualified renders the ORDER BY expression using only the column name, // without a table qualifier. Required for set operation (UNION/INTERSECT/EXCEPT) // ORDER BY clauses, where table qualifiers are not valid SQL. -func (o OrderExpr) ToSQLUnqualified(ctx *BuildContext) string { - name := unqualifiedColRef(o.ref, ctx) +func (o OrderExpr) RenderSQLUnqualified(ctx *BuildContext) (string, error) { + if isNilInterface(o.ref) { + return "", NewError(CodeBuildValidation, "render_order", "order expression is empty") + } + name, err := unqualifiedColRef(o.ref, ctx) + if err != nil { + return "", err + } s := name + " " + o.dir if o.nulls != "" { s += " " + o.nulls } - return s + return s, nil } // unqualifiedColRef returns only the column name portion of a colRef, // stripping any table qualifier. Falls back to the full colRef for complex // expressions (window functions, arithmetic, etc.) that have no table prefix. -func unqualifiedColRef(ref colRefer, ctx *BuildContext) string { +func unqualifiedColRef(ref colRefer, ctx *BuildContext) (string, error) { // For ColBase (the common case), we can access the column name directly. type namedCol interface { ColumnName() string @@ -119,7 +132,7 @@ func (o OrderExpr) NullsLast() OrderExpr { // SelectableColumn can appear in a SELECT clause. Generated table types // expose their columns as SelectableColumn values. type SelectableColumn interface { - colRef(ctx *BuildContext) string + colRef(ctx *BuildContext) (string, error) ColumnName() string TableName() string } @@ -141,7 +154,7 @@ func (c UUIDColumn) NEQ(val uuid.UUID) Expression { } func (c UUIDColumn) In(vals ...uuid.UUID) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -151,7 +164,7 @@ func (c UUIDColumn) In(vals ...uuid.UUID) Expression { } func (c UUIDColumn) NotIn(vals ...uuid.UUID) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -204,38 +217,36 @@ func (c StringColumn) NotILike(pattern string) Expression { } // RegexpMatch produces a case-sensitive regex match: col ~ $1 (PostgreSQL-specific). -// On non-PostgreSQL dialects, emits FALSE and binds no arguments. -// Check dialect.SupportsRegexpMatch() before using this operator for portability. +// On unsupported dialects, rendering returns ErrUnsupportedFeature and binds +// no arguments. func (c StringColumn) RegexpMatch(pattern string) Expression { return regexpExpr{ref: c.ColBase, op: "~", pattern: pattern} } // RegexpMatchI produces a case-insensitive regex match: col ~* $1 (PostgreSQL-specific). -// On non-PostgreSQL dialects, emits FALSE and binds no arguments. -// Check dialect.SupportsRegexpMatch() before using this operator for portability. +// On unsupported dialects, rendering returns ErrUnsupportedFeature and binds +// no arguments. func (c StringColumn) RegexpMatchI(pattern string) Expression { return regexpExpr{ref: c.ColBase, op: "~*", pattern: pattern} } // NotRegexpMatch produces a case-sensitive regex non-match: col !~ $1 (PostgreSQL-specific). -// On non-PostgreSQL dialects, emits FALSE (no rows matched) — not TRUE — and binds no -// arguments. This means expr.Not(col.NotRegexpMatch(...)) on a non-PG dialect yields TRUE. -// Check dialect.SupportsRegexpMatch() before using this operator for portability. +// On unsupported dialects, rendering returns ErrUnsupportedFeature and binds +// no arguments. func (c StringColumn) NotRegexpMatch(pattern string) Expression { return regexpExpr{ref: c.ColBase, op: "!~", pattern: pattern} } // NotRegexpMatchI produces a case-insensitive regex non-match: col !~* $1 (PostgreSQL-specific). -// On non-PostgreSQL dialects, emits FALSE (no rows matched) — not TRUE — and binds no -// arguments. This means expr.Not(col.NotRegexpMatchI(...)) on a non-PG dialect yields TRUE. -// Check dialect.SupportsRegexpMatch() before using this operator for portability. +// On unsupported dialects, rendering returns ErrUnsupportedFeature and binds +// no arguments. func (c StringColumn) NotRegexpMatchI(pattern string) Expression { return regexpExpr{ref: c.ColBase, op: "!~*", pattern: pattern} } func (c StringColumn) In(vals ...string) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -245,7 +256,7 @@ func (c StringColumn) In(vals ...string) Expression { } func (c StringColumn) NotIn(vals ...string) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -289,7 +300,7 @@ func (c IntColumn) Between(lo, hi int) Expression { } func (c IntColumn) In(vals ...int) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -299,7 +310,7 @@ func (c IntColumn) In(vals ...int) Expression { } func (c IntColumn) NotIn(vals ...int) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -366,7 +377,7 @@ func (c BigIntColumn) Between(lo, hi int64) Expression { } func (c BigIntColumn) In(vals ...int64) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -376,7 +387,7 @@ func (c BigIntColumn) In(vals ...int64) Expression { } func (c BigIntColumn) NotIn(vals ...int64) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -583,7 +594,7 @@ func (c FloatColumn) Between(lo, hi float64) Expression { } func (c FloatColumn) In(vals ...float64) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -593,7 +604,7 @@ func (c FloatColumn) In(vals ...float64) Expression { } func (c FloatColumn) NotIn(vals ...float64) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -720,7 +731,7 @@ func (c EnumColumn) NEQ(val string) Expression { } func (c EnumColumn) In(vals ...string) Expression { if len(vals) == 0 { - return Raw("FALSE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -730,7 +741,7 @@ func (c EnumColumn) In(vals ...string) Expression { } func (c EnumColumn) NotIn(vals ...string) Expression { if len(vals) == 0 { - return Raw("TRUE") + return invalidListExpression() } anys := make([]any, len(vals)) for i, v := range vals { @@ -760,8 +771,8 @@ func (c InetColumn) NEQ(val string) Expression { // TsvectorColumn is a typed column handle for PostgreSQL TSVECTOR values. // It exposes PostgreSQL full-text search operators (@@ with various tsquery constructors). -// These operators are PostgreSQL-specific; on non-PostgreSQL dialects all Matches* methods -// emit FALSE and bind no arguments. Check dialect.SupportsFullTextSearch() for portability. +// These operators are PostgreSQL-specific; on other dialects rendering returns +// ErrUnsupportedFeature and binds no arguments. type TsvectorColumn struct{ ColBase } // Matches returns col @@ to_tsquery($1) — matches a tsquery string. diff --git a/expr/context.go b/expr/context.go index bf967a7..02fbc1c 100644 --- a/expr/context.go +++ b/expr/context.go @@ -4,10 +4,15 @@ // mismatched types are compared. package expr -import "github.com/sofired/grizzle/dialect" +import ( + "strings" + "unicode" + + "github.com/sofired/grizzle/dialect" +) // BuildContext accumulates bound parameters and carries the active dialect -// during SQL generation. It is threaded through every ToSQL call. +// during SQL generation. It is threaded through every RenderSQL call. type BuildContext struct { args []any d dialect.Dialect @@ -24,18 +29,30 @@ func (c *BuildContext) Add(val any) string { return c.d.Placeholder(len(c.args)) } -// Quote wraps an identifier in dialect-appropriate quote characters. -func (c *BuildContext) Quote(name string) string { - return c.d.QuoteIdent(name) +// Quote validates, escapes, and wraps one identifier part in +// dialect-appropriate quote characters. +func (c *BuildContext) Quote(name string) (string, error) { + if err := validateIdentifier(name); err != nil { + return "", err + } + return c.d.QuoteIdent(name), nil } // ColRef returns the fully-qualified "table"."column" reference, // or just "column" if table is empty. -func (c *BuildContext) ColRef(table, name string) string { +func (c *BuildContext) ColRef(table, name string) (string, error) { + column, err := c.Quote(name) + if err != nil { + return "", err + } if table != "" { - return c.d.QuoteIdent(table) + "." + c.d.QuoteIdent(name) + qualified, err := c.Quote(table) + if err != nil { + return "", err + } + return qualified + "." + column, nil } - return c.d.QuoteIdent(name) + return column, nil } // Args returns the ordered slice of bound parameter values. @@ -43,3 +60,30 @@ func (c *BuildContext) Args() []any { return c.args } // Dialect returns the active dialect. func (c *BuildContext) Dialect() dialect.Dialect { return c.d } + +// renderAtomically restores the argument slice when a composite expression +// fails after one of its children has already bound values. +func renderAtomically(c *BuildContext, render func() (string, error)) (string, error) { + checkpoint := len(c.args) + sql, err := render() + if err == nil { + return sql, nil + } + for i := checkpoint; i < len(c.args); i++ { + c.args[i] = nil + } + c.args = c.args[:checkpoint] + return "", err +} + +func validateIdentifier(name string) error { + if name == "" || strings.Contains(name, ".") { + return NewError(CodeInvalidIdentifier, "quote_identifier", "identifier part is invalid") + } + for _, r := range name { + if unicode.IsControl(r) { + return NewError(CodeInvalidIdentifier, "quote_identifier", "identifier part is invalid") + } + } + return nil +} diff --git a/expr/errors.go b/expr/errors.go new file mode 100644 index 0000000..66f567d --- /dev/null +++ b/expr/errors.go @@ -0,0 +1,192 @@ +package expr + +import ( + "context" + "errors" + "fmt" + "io" +) + +// ErrorCode is a stable programmatic classification for build and execution +// failures. Callers should prefer errors.Is and errors.As over matching error +// strings. +type ErrorCode string + +const ( + CodeUnsupportedFeature ErrorCode = "unsupported_feature" + CodeUnsupportedDialect ErrorCode = "unsupported_dialect" + CodeInvalidIdentifier ErrorCode = "invalid_identifier" + CodePreparedNotReady ErrorCode = "prepared_not_ready" + CodeRegistryClosed ErrorCode = "registry_closed" + CodeMissingParam ErrorCode = "missing_param" + CodeInvalidParamType ErrorCode = "invalid_param_type" + CodeInvalidParamValue ErrorCode = "invalid_param_value" + CodeParamEncode ErrorCode = "param_encode" + CodeInvalidResultKind ErrorCode = "invalid_prepared_result_kind" + CodeDuplicateRegistry ErrorCode = "duplicate_registry_name" + CodePreparedTxMismatch ErrorCode = "prepared_tx_mismatch" + CodeInvalidReceiver ErrorCode = "invalid_receiver" + CodeBuildValidation ErrorCode = "build_validation" + CodeNotFound ErrorCode = "not_found" + CodeTooManyRows ErrorCode = "too_many_rows" + CodeInvalidRows ErrorCode = "invalid_rows" + CodeScanDecode ErrorCode = "scan_decode" + CodeTransactionBegin ErrorCode = "transaction_begin" + CodeTransactionCommit ErrorCode = "transaction_commit" + CodeTransactionRollback ErrorCode = "transaction_rollback" + CodeTransactionCallback ErrorCode = "transaction_callback" +) + +var ( + ErrUnsupportedFeature = errors.New(string(CodeUnsupportedFeature)) + ErrUnsupportedDialect = errors.New(string(CodeUnsupportedDialect)) + ErrInvalidIdentifier = errors.New(string(CodeInvalidIdentifier)) + ErrPreparedNotReady = errors.New(string(CodePreparedNotReady)) + ErrRegistryClosed = errors.New(string(CodeRegistryClosed)) + ErrMissingParam = errors.New(string(CodeMissingParam)) + ErrInvalidParamType = errors.New(string(CodeInvalidParamType)) + ErrInvalidParamValue = errors.New(string(CodeInvalidParamValue)) + ErrParamEncode = errors.New(string(CodeParamEncode)) + ErrInvalidResultKind = errors.New(string(CodeInvalidResultKind)) + ErrDuplicateRegistry = errors.New(string(CodeDuplicateRegistry)) + ErrPreparedTxMismatch = errors.New(string(CodePreparedTxMismatch)) + ErrInvalidReceiver = errors.New(string(CodeInvalidReceiver)) + ErrBuildValidation = errors.New(string(CodeBuildValidation)) + ErrNotFound = errors.New(string(CodeNotFound)) + ErrTooManyRows = errors.New(string(CodeTooManyRows)) + ErrInvalidRows = errors.New(string(CodeInvalidRows)) + ErrScanDecode = errors.New(string(CodeScanDecode)) + ErrTransactionBegin = errors.New(string(CodeTransactionBegin)) + ErrTransactionCommit = errors.New(string(CodeTransactionCommit)) + ErrTransactionRollback = errors.New(string(CodeTransactionRollback)) + ErrTransactionCallback = errors.New(string(CodeTransactionCallback)) +) + +// Error is a redacted Grizzle error with a stable code and operation. +// Err, when set by Grizzle, contains only a safe sentinel such as context +// cancellation; raw SQL, values, identifiers, and driver errors are never +// stored here. +type Error struct { + Code ErrorCode + Op string + Message string + Err error +} + +// NewError returns a stable, redacted build error. +func NewError(code ErrorCode, op, message string) *Error { + return &Error{Code: code, Op: op, Message: message} +} + +// Error returns a redacted diagnostic containing no SQL or input values. +func (e *Error) Error() string { + if e == nil { + return "" + } + message := e.Message + if message == "" { + message = string(e.Code) + } + if e.Op == "" { + return message + } + return e.Op + ": " + message +} + +// Unwrap exposes only stable sentinels and the standard context sentinels. +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + if errors.Is(e.Err, context.Canceled) { + return context.Canceled + } + if errors.Is(e.Err, context.DeadlineExceeded) { + return context.DeadlineExceeded + } + return sentinelForCode(e.Code) +} + +// Is preserves both the stable Grizzle classification and safe context +// cancellation sentinels when Err carries a context cause. +func (e *Error) Is(target error) bool { + if e == nil { + return false + } + if sentinel := sentinelForCode(e.Code); sentinel != nil && target == sentinel { + return true + } + switch target { + case context.Canceled: + return errors.Is(e.Err, context.Canceled) + case context.DeadlineExceeded: + return errors.Is(e.Err, context.DeadlineExceeded) + default: + return false + } +} + +// Format prevents %+v from exposing the Error struct or its Err field. +func (e *Error) Format(s fmt.State, verb rune) { + switch verb { + case 'v', 's', 'q': + if verb == 'q' { + _, _ = fmt.Fprintf(s, "%q", e.Error()) + return + } + _, _ = io.WriteString(s, e.Error()) + default: + _, _ = io.WriteString(s, e.Error()) + } +} + +func sentinelForCode(code ErrorCode) error { + switch code { + case CodeUnsupportedFeature: + return ErrUnsupportedFeature + case CodeUnsupportedDialect: + return ErrUnsupportedDialect + case CodeInvalidIdentifier: + return ErrInvalidIdentifier + case CodePreparedNotReady: + return ErrPreparedNotReady + case CodeRegistryClosed: + return ErrRegistryClosed + case CodeMissingParam: + return ErrMissingParam + case CodeInvalidParamType: + return ErrInvalidParamType + case CodeInvalidParamValue: + return ErrInvalidParamValue + case CodeParamEncode: + return ErrParamEncode + case CodeInvalidResultKind: + return ErrInvalidResultKind + case CodeDuplicateRegistry: + return ErrDuplicateRegistry + case CodePreparedTxMismatch: + return ErrPreparedTxMismatch + case CodeInvalidReceiver: + return ErrInvalidReceiver + case CodeBuildValidation: + return ErrBuildValidation + case CodeNotFound: + return ErrNotFound + case CodeTooManyRows: + return ErrTooManyRows + case CodeInvalidRows: + return ErrInvalidRows + case CodeScanDecode: + return ErrScanDecode + case CodeTransactionBegin: + return ErrTransactionBegin + case CodeTransactionCommit: + return ErrTransactionCommit + case CodeTransactionRollback: + return ErrTransactionRollback + case CodeTransactionCallback: + return ErrTransactionCallback + default: + return nil + } +} diff --git a/expr/errors_test.go b/expr/errors_test.go new file mode 100644 index 0000000..ede23fe --- /dev/null +++ b/expr/errors_test.go @@ -0,0 +1,119 @@ +package expr_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/sofired/grizzle/dialect" + "github.com/sofired/grizzle/expr" +) + +func TestError_StableClassificationAndRedaction(t *testing.T) { + const secret = "raw SQL: SELECT password FROM users" + err := expr.NewError(expr.CodeBuildValidation, "render_test", "expression is invalid") + err.Err = errors.New(secret) + + if !errors.Is(err, expr.ErrBuildValidation) { + t.Fatal("errors.Is did not match ErrBuildValidation") + } + var target *expr.Error + if !errors.As(err, &target) { + t.Fatal("errors.As did not expose *expr.Error") + } + if target.Code != expr.CodeBuildValidation || target.Op != "render_test" { + t.Fatalf("unexpected error shape: %#v", target) + } + for _, rendered := range []string{err.Error(), fmt.Sprintf("%v", err), fmt.Sprintf("%+v", err), fmt.Sprintf("%q", err)} { + if strings.Contains(rendered, secret) { + t.Fatalf("diagnostic leaked unsafe cause: %q", rendered) + } + } +} + +func TestError_PreservesCodeAndSafeContextSentinel(t *testing.T) { + err := expr.NewError(expr.CodeBuildValidation, "render_test", "expression is invalid") + err.Err = context.Canceled + if !errors.Is(err, expr.ErrBuildValidation) { + t.Fatal("context error lost stable build classification") + } + if !errors.Is(err, context.Canceled) { + t.Fatal("context error lost cancellation sentinel") + } +} + +func TestBuildContext_QuoteIdentifierContract(t *testing.T) { + valid := []struct { + name string + d dialect.Dialect + in string + want string + }{ + {"postgres quote", dialect.Postgres, `a"b`, `"a""b"`}, + {"sqlite quote", dialect.SQLite, `a"b`, `"a""b"`}, + {"mysql quote", dialect.MySQL, "a`b", "`a``b`"}, + } + for _, tc := range valid { + t.Run(tc.name, func(t *testing.T) { + got, err := expr.NewBuildContext(tc.d).Quote(tc.in) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("Quote() = %q, want %q", got, tc.want) + } + }) + } + + invalid := []string{"", "audit.users", "nul\x00byte", "line\nfeed", "carriage\rreturn", "delete\x7fchar", "control\x01char"} + for _, name := range invalid { + t.Run(fmt.Sprintf("invalid_%x", name), func(t *testing.T) { + got, err := expr.NewBuildContext(dialect.Postgres).Quote(name) + if got != "" { + t.Fatalf("Quote() rendered unsafe identifier: %q", got) + } + if !errors.Is(err, expr.ErrInvalidIdentifier) { + t.Fatalf("error = %v, want ErrInvalidIdentifier", err) + } + if name != "" && strings.Contains(err.Error(), name) { + t.Fatalf("error leaked identifier: %q", err) + } + }) + } +} + +func TestCompositeRender_RollsBackArgumentsOnError(t *testing.T) { + ctx := expr.NewBuildContext(dialect.Postgres) + e := expr.And( + expr.Lit("sensitive-value"), + expr.RawArgs("x = $? AND y = $?", 1), + ) + got, err := e.RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("got (%q, %v), want empty SQL and ErrBuildValidation", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("failed render retained arguments: %v", ctx.Args()) + } +} + +func TestZeroValueExpressions_ReturnBuildValidation(t *testing.T) { + ctx := expr.NewBuildContext(dialect.Postgres) + expressions := []expr.Expression{ + expr.AggExpr{}, + expr.FuncExpr{}, + expr.WindowExpr{}, + expr.TsvectorExpr{}, + } + for _, expression := range expressions { + got, err := expression.RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("%T rendered (%q, %v), want empty SQL and ErrBuildValidation", expression, got, err) + } + } + if got, err := (expr.OrderExpr{}).RenderSQL(ctx); got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("zero OrderExpr rendered (%q, %v), want empty SQL and ErrBuildValidation", got, err) + } +} diff --git a/expr/example_test.go b/expr/example_test.go index bb22ac3..611d9be 100644 --- a/expr/example_test.go +++ b/expr/example_test.go @@ -8,6 +8,14 @@ import ( ts "github.com/sofired/grizzle/internal/testschema" ) +func mustRender(e expr.Expression, ctx *expr.BuildContext) string { + sql, err := e.RenderSQL(ctx) + if err != nil { + panic(err) + } + return sql +} + // ExampleAnd demonstrates nil-safe AND: nil expressions are silently dropped, // making dynamic WHERE clauses safe to construct without explicit nil checks. func ExampleAnd() { @@ -19,7 +27,7 @@ func ExampleAnd() { emailFilter, // nil — silently dropped ) ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(cond.ToSQL(ctx)) + fmt.Println(mustRender(cond, ctx)) // Output: // ("users"."deleted_at" IS NULL AND "users"."enabled" = $1) } @@ -31,7 +39,7 @@ func ExampleOr() { ts.UsersT.Email.EQ("alice@example.com"), ) ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(cond.ToSQL(ctx)) + fmt.Println(mustRender(cond, ctx)) // Output: // ("users"."email" IS NULL OR "users"."email" = $1) } @@ -40,7 +48,7 @@ func ExampleOr() { func ExampleNot() { cond := expr.Not(ts.UsersT.DeletedAt.IsNull()) ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(cond.ToSQL(ctx)) + fmt.Println(mustRender(cond, ctx)) // Output: // NOT ("users"."deleted_at" IS NULL) } @@ -54,7 +62,7 @@ func ExampleCase() { Else(expr.Lit("inactive")). As("status") ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(status.ToSQL(ctx)) + fmt.Println(mustRender(status, ctx)) // Output: // CASE WHEN "users"."deleted_at" IS NOT NULL THEN $1 WHEN "users"."enabled" = $2 THEN $3 ELSE $4 END AS "status" } @@ -65,7 +73,7 @@ func ExampleCase() { func ExampleLit() { v := expr.Lit(42) ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(v.ToSQL(ctx)) + fmt.Println(mustRender(v, ctx)) // Output: // $1 } @@ -75,7 +83,7 @@ func ExampleLit() { func ExampleRaw() { e := expr.Raw("now()") ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // now() } @@ -88,7 +96,7 @@ func ExampleRawArgs() { lon, lat, radius := -97.7431, 30.2672, 5000.0 e := expr.RawArgs("ST_DWithin(location, ST_MakePoint($?, $?), $?)", lon, lat, radius) ctx := expr.NewBuildContext(dialect.Postgres) - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) fmt.Println(ctx.Args()) // Output: // ST_DWithin(location, ST_MakePoint($1, $2), $3) diff --git a/expr/expr.go b/expr/expr.go index c2e1567..c1f8ba2 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -1,7 +1,7 @@ package expr import ( - "fmt" + "reflect" "strings" ) @@ -9,14 +9,14 @@ import ( // All concrete expression types are in this package; external packages may also // implement Expression for custom SQL fragments. type Expression interface { - ToSQL(ctx *BuildContext) string + RenderSQL(ctx *BuildContext) (string, error) } // ------------------------------------------------------------------- // Logical combinators // ------------------------------------------------------------------- -// And combines expressions with AND. Nil expressions are silently dropped, +// And combines expressions with AND. Plain nil expressions are silently dropped, // so callers can write: // // And( @@ -37,7 +37,7 @@ func And(exprs ...Expression) Expression { } } -// Or combines expressions with OR. Nil expressions are silently dropped. +// Or combines expressions with OR. Plain nil expressions are silently dropped. func Or(exprs ...Expression) Expression { active := filterNil(exprs) switch len(active) { @@ -50,46 +50,89 @@ func Or(exprs ...Expression) Expression { } } -// Not negates an expression. Returns nil if expr is nil. +// Not negates an expression. Returns nil if expr is a plain nil interface. func Not(expr Expression) Expression { if expr == nil { return nil } + if isNilExpression(expr) { + return invalidExpr{message: "negated expression is typed nil"} + } return notExpr{expr: expr} } func filterNil(exprs []Expression) []Expression { out := exprs[:0:len(exprs)] for _, e := range exprs { - if e != nil { - out = append(out, e) + if e == nil { + continue + } + if isNilExpression(e) { + out = append(out, invalidExpr{message: "logical expression is typed nil"}) + continue } + out = append(out, e) } return out } -type andExpr struct{ exprs []Expression } -type orExpr struct{ exprs []Expression } -type notExpr struct{ expr Expression } +func isNilExpression(e Expression) bool { + return isNilInterface(e) +} -func (e andExpr) ToSQL(ctx *BuildContext) string { - parts := make([]string, len(e.exprs)) - for i, ex := range e.exprs { - parts[i] = ex.ToSQL(ctx) +func isNilInterface(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + default: + return false } - return "(" + strings.Join(parts, " AND ") + ")" } -func (e orExpr) ToSQL(ctx *BuildContext) string { - parts := make([]string, len(e.exprs)) - for i, ex := range e.exprs { - parts[i] = ex.ToSQL(ctx) - } - return "(" + strings.Join(parts, " OR ") + ")" +type andExpr struct{ exprs []Expression } +type orExpr struct{ exprs []Expression } +type notExpr struct{ expr Expression } + +func (e andExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + parts := make([]string, len(e.exprs)) + for i, ex := range e.exprs { + part, err := ex.RenderSQL(ctx) + if err != nil { + return "", err + } + parts[i] = part + } + return "(" + strings.Join(parts, " AND ") + ")", nil + }) +} + +func (e orExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + parts := make([]string, len(e.exprs)) + for i, ex := range e.exprs { + part, err := ex.RenderSQL(ctx) + if err != nil { + return "", err + } + parts[i] = part + } + return "(" + strings.Join(parts, " OR ") + ")", nil + }) } -func (e notExpr) ToSQL(ctx *BuildContext) string { - return "NOT (" + e.expr.ToSQL(ctx) + ")" +func (e notExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + inner, err := e.expr.RenderSQL(ctx) + if err != nil { + return "", err + } + return "NOT (" + inner + ")", nil + }) } // ------------------------------------------------------------------- @@ -102,16 +145,24 @@ func Raw(sql string) Expression { return rawExpr{sql: sql} } type rawExpr struct{ sql string } -func (e rawExpr) ToSQL(_ *BuildContext) string { return e.sql } +func (e rawExpr) RenderSQL(_ *BuildContext) (string, error) { return e.sql, nil } + +type invalidExpr struct{ message string } + +func (e invalidExpr) RenderSQL(_ *BuildContext) (string, error) { + return "", NewError(CodeBuildValidation, "render_expression", e.message) +} + +func invalidListExpression() Expression { + return invalidExpr{message: "list expression requires at least one value"} +} // RawArgs wraps a SQL fragment containing $? placeholders together with the // argument values that fill them in order. Each $? placeholder is replaced with // the next bound-parameter placeholder ($1, ?, etc.) from the active dialect. // -// The number of $? tokens must exactly match the number of args. Any mismatch — -// too few args or too many args — causes a panic at query-build time with a -// message that identifies the template and the counts. This strict arity check -// prevents silently invalid SQL from reaching the database. +// The number of $? tokens must exactly match the number of args. Any mismatch +// returns a redacted build-validation error before binding arguments. // // Example: // @@ -125,12 +176,11 @@ type rawArgsExpr struct { args []any } -func (e rawArgsExpr) ToSQL(ctx *BuildContext) string { +func (e rawArgsExpr) RenderSQL(ctx *BuildContext) (string, error) { // Count $? placeholders. count := strings.Count(e.sql, "$?") if count != len(e.args) { - panic(fmt.Sprintf("expr.RawArgs: placeholder count (%d) does not match arg count (%d) in %q", - count, len(e.args), e.sql)) + return "", NewError(CodeBuildValidation, "render_raw_args", "raw expression placeholder count does not match argument count") } // Replace each $? with the next dialect placeholder, binding each arg. result := e.sql @@ -140,7 +190,7 @@ func (e rawArgsExpr) ToSQL(ctx *BuildContext) string { idx := strings.Index(result, "$?") result = result[:idx] + placeholder + result[idx+2:] } - return result + return result, nil } // ------------------------------------------------------------------- @@ -154,8 +204,12 @@ type binaryExpr struct { val any } -func (e binaryExpr) ToSQL(ctx *BuildContext) string { - return e.ref.colRef(ctx) + " " + e.op + " " + ctx.Add(e.val) +func (e binaryExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " " + e.op + " " + ctx.Add(e.val), nil } // colColExpr holds a column op column comparison: "t1"."c1" OP "t2"."c2" @@ -165,8 +219,16 @@ type colColExpr struct { right colRefer } -func (e colColExpr) ToSQL(ctx *BuildContext) string { - return e.left.colRef(ctx) + " " + e.op + " " + e.right.colRef(ctx) +func (e colColExpr) RenderSQL(ctx *BuildContext) (string, error) { + left, err := e.left.colRef(ctx) + if err != nil { + return "", err + } + right, err := e.right.colRef(ctx) + if err != nil { + return "", err + } + return left + " " + e.op + " " + right, nil } // nullExpr holds IS NULL / IS NOT NULL @@ -175,11 +237,15 @@ type nullExpr struct { isNull bool } -func (e nullExpr) ToSQL(ctx *BuildContext) string { +func (e nullExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } if e.isNull { - return e.ref.colRef(ctx) + " IS NULL" + return ref + " IS NULL", nil } - return e.ref.colRef(ctx) + " IS NOT NULL" + return ref + " IS NOT NULL", nil } // inExpr holds col IN (v1, v2, ...) @@ -189,7 +255,11 @@ type inExpr struct { not bool } -func (e inExpr) ToSQL(ctx *BuildContext) string { +func (e inExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } placeholders := make([]string, len(e.vals)) for i, v := range e.vals { placeholders[i] = ctx.Add(v) @@ -198,7 +268,7 @@ func (e inExpr) ToSQL(ctx *BuildContext) string { if e.not { op = "NOT IN" } - return e.ref.colRef(ctx) + " " + op + " (" + strings.Join(placeholders, ", ") + ")" + return ref + " " + op + " (" + strings.Join(placeholders, ", ") + ")", nil } // betweenExpr holds col BETWEEN lo AND hi @@ -208,9 +278,12 @@ type betweenExpr struct { hi any } -func (e betweenExpr) ToSQL(ctx *BuildContext) string { - return fmt.Sprintf("%s BETWEEN %s AND %s", - e.ref.colRef(ctx), ctx.Add(e.lo), ctx.Add(e.hi)) +func (e betweenExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " BETWEEN " + ctx.Add(e.lo) + " AND " + ctx.Add(e.hi), nil } // likeExpr holds col LIKE/ILIKE pattern @@ -220,15 +293,19 @@ type likeExpr struct { pattern string } -func (e likeExpr) ToSQL(ctx *BuildContext) string { - return e.ref.colRef(ctx) + " " + e.op + " " + ctx.Add(e.pattern) +func (e likeExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " " + e.op + " " + ctx.Add(e.pattern), nil } // colRefer is the internal interface that column types implement. // It gives expression constructors access to the quoted column reference // without exposing the BuildContext publicly on every column method. type colRefer interface { - colRef(ctx *BuildContext) string + colRef(ctx *BuildContext) (string, error) } // ------------------------------------------------------------------- @@ -243,8 +320,12 @@ type rawFlipExpr struct { ref colRefer } -func (e rawFlipExpr) ToSQL(ctx *BuildContext) string { - return ctx.Add(e.left) + " " + e.op + " " + e.ref.colRef(ctx) +func (e rawFlipExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ctx.Add(e.left) + " " + e.op + " " + ref, nil } // jsonbNavExpr represents col -> key or col ->> key (text extraction). @@ -255,8 +336,12 @@ type jsonbNavExpr struct { key string // text key (for ->) or integer index as string (for array access) } -func (e jsonbNavExpr) ToSQL(ctx *BuildContext) string { - return e.ref.colRef(ctx) + " " + e.op + " " + ctx.Add(e.key) +func (e jsonbNavExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " " + e.op + " " + ctx.Add(e.key), nil } // jsonbPathExpr represents col #> path or col #>> path (path extraction). @@ -266,13 +351,17 @@ type jsonbPathExpr struct { path []string // path segments e.g. {"a","b","c"} } -func (e jsonbPathExpr) ToSQL(ctx *BuildContext) string { +func (e jsonbPathExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } // PostgreSQL path syntax: ARRAY['a','b','c']::text[] quoted := make([]string, len(e.path)) for i, seg := range e.path { quoted[i] = "'" + seg + "'" } - return e.ref.colRef(ctx) + " " + e.op + " ARRAY[" + strings.Join(quoted, ", ") + "]" + return ref + " " + e.op + " ARRAY[" + strings.Join(quoted, ", ") + "]", nil } // jsonbContainsExpr represents col @> val::jsonb (containment check). @@ -282,12 +371,16 @@ type jsonbContainsExpr struct { not bool } -func (e jsonbContainsExpr) ToSQL(ctx *BuildContext) string { +func (e jsonbContainsExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } op := "@>" if e.not { - return "NOT " + e.ref.colRef(ctx) + " @> " + ctx.Add(e.val) + return "NOT " + ref + " @> " + ctx.Add(e.val), nil } - return e.ref.colRef(ctx) + " " + op + " " + ctx.Add(e.val) + return ref + " " + op + " " + ctx.Add(e.val), nil } // jsonbKeyExistsExpr represents col ? key (key existence check). @@ -297,11 +390,15 @@ type jsonbKeyExistsExpr struct { not bool } -func (e jsonbKeyExistsExpr) ToSQL(ctx *BuildContext) string { +func (e jsonbKeyExistsExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } if e.not { - return "NOT " + e.ref.colRef(ctx) + " ? " + ctx.Add(e.key) + return "NOT " + ref + " ? " + ctx.Add(e.key), nil } - return e.ref.colRef(ctx) + " ? " + ctx.Add(e.key) + return ref + " ? " + ctx.Add(e.key), nil } // jsonbAnyKeyExistsExpr represents col ?| keys (any key exists). @@ -310,8 +407,12 @@ type jsonbAnyKeyExistsExpr struct { keys []string } -func (e jsonbAnyKeyExistsExpr) ToSQL(ctx *BuildContext) string { - return e.ref.colRef(ctx) + " ?| " + ctx.Add(e.keys) +func (e jsonbAnyKeyExistsExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " ?| " + ctx.Add(e.keys), nil } // jsonbAllKeysExistExpr represents col ?& keys (all keys exist). @@ -320,6 +421,10 @@ type jsonbAllKeysExistExpr struct { keys []string } -func (e jsonbAllKeysExistExpr) ToSQL(ctx *BuildContext) string { - return e.ref.colRef(ctx) + " ?& " + ctx.Add(e.keys) +func (e jsonbAllKeysExistExpr) RenderSQL(ctx *BuildContext) (string, error) { + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " ?& " + ctx.Add(e.keys), nil } diff --git a/expr/fn.go b/expr/fn.go index db68222..47a521c 100644 --- a/expr/fn.go +++ b/expr/fn.go @@ -14,7 +14,10 @@ func Col(col SelectableColumn) Expression { return colAsExpr{col: col} } type colAsExpr struct{ col SelectableColumn } -func (e colAsExpr) ToSQL(ctx *BuildContext) string { +func (e colAsExpr) RenderSQL(ctx *BuildContext) (string, error) { + if isNilInterface(e.col) { + return "", NewError(CodeBuildValidation, "render_column", "column is nil") + } // Use internal colRef when available (preserves complex expressions like // window functions, aggregates, arithmetic) — fall back to ColRef otherwise. if cr, ok := e.col.(colRefer); ok { @@ -31,14 +34,17 @@ func (e colAsExpr) ToSQL(ctx *BuildContext) string { // arguments (arithmetic right-hand sides, etc.). type litRefer struct{ v any } -func (l litRefer) colRef(ctx *BuildContext) string { return ctx.Add(l.v) } +func (l litRefer) colRef(ctx *BuildContext) (string, error) { return ctx.Add(l.v), nil } // colSelAsRef wraps a SelectableColumn as a colRefer — used internally so // column types can pass themselves to ArithExpr without needing to // expose the private colRefer interface. type colSelAsRef struct{ col SelectableColumn } -func (c colSelAsRef) colRef(ctx *BuildContext) string { +func (c colSelAsRef) colRef(ctx *BuildContext) (string, error) { + if isNilInterface(c.col) { + return "", NewError(CodeBuildValidation, "render_column", "column is nil") + } if cr, ok := c.col.(colRefer); ok { return cr.colRef(ctx) } @@ -62,25 +68,46 @@ type FuncExpr struct { } // renderCore renders the function call without the alias. -func (f FuncExpr) renderCore(ctx *BuildContext) string { - parts := make([]string, len(f.args)) - for i, a := range f.args { - parts[i] = a.ToSQL(ctx) - } - return f.fn + "(" + strings.Join(parts, ", ") + ")" +func (f FuncExpr) renderCore(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + if f.fn == "" { + return "", NewError(CodeBuildValidation, "render_function", "function name is empty") + } + parts := make([]string, len(f.args)) + for i, a := range f.args { + if isNilExpression(a) { + return "", NewError(CodeBuildValidation, "render_function", "function argument is nil") + } + part, err := a.RenderSQL(ctx) + if err != nil { + return "", err + } + parts[i] = part + } + return f.fn + "(" + strings.Join(parts, ", ") + ")", nil + }) } -// ToSQL implements Expression. Includes the AS alias when set (for SELECT). -func (f FuncExpr) ToSQL(ctx *BuildContext) string { - s := f.renderCore(ctx) - if f.alias != "" { - s += " AS " + ctx.Quote(f.alias) - } - return s +// RenderSQL implements Expression. Includes the AS alias when set (for SELECT). +func (f FuncExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + s, err := f.renderCore(ctx) + if err != nil { + return "", err + } + if f.alias != "" { + alias, err := ctx.Quote(f.alias) + if err != nil { + return "", err + } + s += " AS " + alias + } + return s, nil + }) } // colRef implements colRefer (no alias — for use inside other expressions). -func (f FuncExpr) colRef(ctx *BuildContext) string { return f.renderCore(ctx) } +func (f FuncExpr) colRef(ctx *BuildContext) (string, error) { return f.renderCore(ctx) } // ColumnName implements SelectableColumn. func (f FuncExpr) ColumnName() string { @@ -143,21 +170,43 @@ type ArithExpr struct { } // renderCore renders the arithmetic expression without the alias. -func (a ArithExpr) renderCore(ctx *BuildContext) string { - return "(" + a.left.colRef(ctx) + " " + a.op + " " + a.right.colRef(ctx) + ")" +func (a ArithExpr) renderCore(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + if isNilInterface(a.left) || isNilInterface(a.right) { + return "", NewError(CodeBuildValidation, "render_arithmetic", "arithmetic operand is nil") + } + left, err := a.left.colRef(ctx) + if err != nil { + return "", err + } + right, err := a.right.colRef(ctx) + if err != nil { + return "", err + } + return "(" + left + " " + a.op + " " + right + ")", nil + }) } -// ToSQL implements Expression. Includes AS alias when set (for SELECT). -func (a ArithExpr) ToSQL(ctx *BuildContext) string { - s := a.renderCore(ctx) - if a.alias != "" { - s += " AS " + ctx.Quote(a.alias) - } - return s +// RenderSQL implements Expression. Includes AS alias when set (for SELECT). +func (a ArithExpr) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + s, err := a.renderCore(ctx) + if err != nil { + return "", err + } + if a.alias != "" { + alias, err := ctx.Quote(a.alias) + if err != nil { + return "", err + } + s += " AS " + alias + } + return s, nil + }) } // colRef implements colRefer (no alias — for use inside other expressions). -func (a ArithExpr) colRef(ctx *BuildContext) string { return a.renderCore(ctx) } +func (a ArithExpr) colRef(ctx *BuildContext) (string, error) { return a.renderCore(ctx) } // ColumnName implements SelectableColumn. func (a ArithExpr) ColumnName() string { return a.alias } @@ -292,7 +341,7 @@ func TsRankCd(col SelectableColumn, tsq Expression) FuncExpr { // AliasedCol wraps a SelectableColumn and adds a SELECT-list alias. // The AS clause is only emitted when the column appears in a SELECT list -// (via ToSQL); colRef — used internally for ORDER BY and GROUP BY — emits +// (via RenderSQL); colRef — used internally for ORDER BY and GROUP BY — emits // only the underlying column reference without the alias. // // Usage: @@ -310,18 +359,30 @@ func ColAs(col SelectableColumn, alias string) AliasedCol { return AliasedCol{col: col, alias: alias} } -// ToSQL emits "col AS alias" — for SELECT list position. -func (a AliasedCol) ToSQL(ctx *BuildContext) string { - ref := a.colRef(ctx) - if a.alias != "" { - return ref + " AS " + ctx.Quote(a.alias) - } - return ref +// RenderSQL emits "col AS alias" — for SELECT list position. +func (a AliasedCol) RenderSQL(ctx *BuildContext) (string, error) { + return renderAtomically(ctx, func() (string, error) { + ref, err := a.colRef(ctx) + if err != nil { + return "", err + } + if a.alias != "" { + alias, err := ctx.Quote(a.alias) + if err != nil { + return "", err + } + return ref + " AS " + alias, nil + } + return ref, nil + }) } // colRef emits only the underlying column reference — no alias. // Used in ORDER BY, GROUP BY, and any other non-SELECT position. -func (a AliasedCol) colRef(ctx *BuildContext) string { +func (a AliasedCol) colRef(ctx *BuildContext) (string, error) { + if isNilInterface(a.col) { + return "", NewError(CodeBuildValidation, "render_column", "aliased column is nil") + } if cr, ok := a.col.(colRefer); ok { return cr.colRef(ctx) } diff --git a/expr/fn_fts_test.go b/expr/fn_fts_test.go index bc4a92d..ffd4ae5 100644 --- a/expr/fn_fts_test.go +++ b/expr/fn_fts_test.go @@ -15,7 +15,7 @@ import ( func TestToTsquery_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.ToTsquery("fat & rat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "to_tsquery($1)" { @@ -29,7 +29,7 @@ func TestToTsquery_ArgOrder(t *testing.T) { func TestToTsqueryWithConfig_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.ToTsqueryWithConfig("english", "fat & rat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "to_tsquery($1, $2)" { @@ -49,7 +49,7 @@ func TestToTsqueryWithConfig_ArgOrder(t *testing.T) { func TestPlainToTsquery_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.PlainToTsquery("fat rat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "plainto_tsquery($1)" { @@ -63,7 +63,7 @@ func TestPlainToTsquery_ArgOrder(t *testing.T) { func TestPlainToTsqueryWithConfig_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.PlainToTsqueryWithConfig("english", "fat rat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "plainto_tsquery($1, $2)" { @@ -83,7 +83,7 @@ func TestPlainToTsqueryWithConfig_ArgOrder(t *testing.T) { func TestPhraseToTsquery_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.PhraseToTsquery("fat cat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "phraseto_tsquery($1)" { @@ -97,7 +97,7 @@ func TestPhraseToTsquery_ArgOrder(t *testing.T) { func TestPhraseToTsqueryWithConfig_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.PhraseToTsqueryWithConfig("english", "fat cat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "phraseto_tsquery($1, $2)" { @@ -117,7 +117,7 @@ func TestPhraseToTsqueryWithConfig_ArgOrder(t *testing.T) { func TestWebsearchToTsquery_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.WebsearchToTsquery("fat cat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "websearch_to_tsquery($1)" { @@ -131,7 +131,7 @@ func TestWebsearchToTsquery_ArgOrder(t *testing.T) { func TestWebsearchToTsqueryWithConfig_ArgOrder(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.WebsearchToTsqueryWithConfig("english", "fat cat") - sql := e.ToSQL(ctx) + sql, _ := e.RenderSQL(ctx) args := ctx.Args() if sql != "websearch_to_tsquery($1, $2)" { diff --git a/expr/pg_text_search.go b/expr/pg_text_search.go index b6b2404..bf4eecf 100644 --- a/expr/pg_text_search.go +++ b/expr/pg_text_search.go @@ -12,11 +12,15 @@ type regexpExpr struct { pattern string } -func (e regexpExpr) ToSQL(ctx *BuildContext) string { +func (e regexpExpr) RenderSQL(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsRegexpMatch() { - return "FALSE" + return "", NewError(CodeUnsupportedFeature, "render_regexp", "regular expressions are not supported by this dialect") } - return e.ref.colRef(ctx) + " " + e.op + " " + ctx.Add(e.pattern) + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } + return ref + " " + e.op + " " + ctx.Add(e.pattern), nil } // ------------------------------------------------------------------- @@ -36,9 +40,13 @@ type ftsMatchExpr struct { hasConfig bool } -func (e ftsMatchExpr) ToSQL(ctx *BuildContext) string { +func (e ftsMatchExpr) RenderSQL(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsFullTextSearch() { - return "FALSE" + return "", NewError(CodeUnsupportedFeature, "render_full_text_search", "full-text search is not supported by this dialect") + } + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err } var tsq string if e.hasConfig { @@ -46,7 +54,7 @@ func (e ftsMatchExpr) ToSQL(ctx *BuildContext) string { } else { tsq = e.tsFn + "(" + ctx.Add(e.query) + ")" } - return e.ref.colRef(ctx) + " @@ " + tsq + return ref + " @@ " + tsq, nil } // TsvectorExpr represents to_tsvector(col) or to_tsvector($config, col) — @@ -62,9 +70,8 @@ func (e ftsMatchExpr) ToSQL(ctx *BuildContext) string { // (e.g. store it in a struct field, accept it as a parameter, or return it // from a helper). // -// On dialects that do not support full-text search, ToSQL emits NULL; if an -// alias is set via As, the output is NULL AS "alias" so that the SELECT -// column position is preserved for struct/row mapping by column name. +// On dialects that do not support full-text search, rendering returns an +// unsupported-feature error without emitting SQL. type TsvectorExpr struct { config string ref colRefer @@ -72,30 +79,43 @@ type TsvectorExpr struct { hasConfig bool } -func (e TsvectorExpr) renderCore(ctx *BuildContext) string { +func (e TsvectorExpr) renderCore(ctx *BuildContext) (string, error) { + if isNilInterface(e.ref) { + return "", NewError(CodeBuildValidation, "render_tsvector", "full-text search column is nil") + } + ref, err := e.ref.colRef(ctx) + if err != nil { + return "", err + } if e.hasConfig { - return "to_tsvector(" + ctx.Add(e.config) + ", " + e.ref.colRef(ctx) + ")" + return "to_tsvector(" + ctx.Add(e.config) + ", " + ref + ")", nil } - return "to_tsvector(" + e.ref.colRef(ctx) + ")" + return "to_tsvector(" + ref + ")", nil } -func (e TsvectorExpr) ToSQL(ctx *BuildContext) string { +func (e TsvectorExpr) RenderSQL(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsFullTextSearch() { + return "", NewError(CodeUnsupportedFeature, "render_tsvector", "full-text search is not supported by this dialect") + } + return renderAtomically(ctx, func() (string, error) { + s, err := e.renderCore(ctx) + if err != nil { + return "", err + } if e.alias != "" { - return "NULL AS " + ctx.Quote(e.alias) + alias, err := ctx.Quote(e.alias) + if err != nil { + return "", err + } + s += " AS " + alias } - return "NULL" - } - s := e.renderCore(ctx) - if e.alias != "" { - s += " AS " + ctx.Quote(e.alias) - } - return s + return s, nil + }) } -func (e TsvectorExpr) colRef(ctx *BuildContext) string { +func (e TsvectorExpr) colRef(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsFullTextSearch() { - return "NULL" + return "", NewError(CodeUnsupportedFeature, "render_tsvector", "full-text search is not supported by this dialect") } return e.renderCore(ctx) } @@ -111,8 +131,7 @@ func (e TsvectorExpr) ColumnName() string { // TableName implements SelectableColumn. func (e TsvectorExpr) TableName() string { return "" } -// As returns a copy with the given SELECT alias. The alias is retained in the -// non-PG NULL fallback: ToSQL emits NULL AS "alias" so column mapping is stable. +// As returns a copy with the given SELECT alias. func (e TsvectorExpr) As(alias string) TsvectorExpr { e.alias = alias; return e } // Matches returns an @@ expression: to_tsvector(...) @@ to_tsquery($1). @@ -202,20 +221,23 @@ type ftsMatchExprOnExpr struct { hasConfig bool } -func (e ftsMatchExprOnExpr) ToSQL(ctx *BuildContext) string { +func (e ftsMatchExprOnExpr) RenderSQL(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsFullTextSearch() { - return "FALSE" + return "", NewError(CodeUnsupportedFeature, "render_full_text_search", "full-text search is not supported by this dialect") } // Render the left side first so its args are bound before the tsquery args, // preserving left-to-right parameter numbering ($1, $2, ...). - left := e.left.colRef(ctx) + left, err := e.left.colRef(ctx) + if err != nil { + return "", err + } var tsq string if e.hasConfig { tsq = e.tsFn + "(" + ctx.Add(e.config) + ", " + ctx.Add(e.query) + ")" } else { tsq = e.tsFn + "(" + ctx.Add(e.query) + ")" } - return left + " @@ " + tsq + return left + " @@ " + tsq, nil } // tsQueryFnExpr represents a standalone tsquery constructor: fn($query) or fn($config, $query). @@ -230,12 +252,12 @@ type tsQueryFnExpr struct { hasConfig bool } -func (e tsQueryFnExpr) ToSQL(ctx *BuildContext) string { +func (e tsQueryFnExpr) RenderSQL(ctx *BuildContext) (string, error) { if !ctx.Dialect().SupportsFullTextSearch() { - return "NULL" + return "", NewError(CodeUnsupportedFeature, "render_tsquery", "full-text search is not supported by this dialect") } if e.hasConfig { - return e.fn + "(" + ctx.Add(e.config) + ", " + ctx.Add(e.query) + ")" + return e.fn + "(" + ctx.Add(e.config) + ", " + ctx.Add(e.query) + ")", nil } - return e.fn + "(" + ctx.Add(e.query) + ")" + return e.fn + "(" + ctx.Add(e.query) + ")", nil } diff --git a/expr/pg_text_search_test.go b/expr/pg_text_search_test.go index a283e80..3c06111 100644 --- a/expr/pg_text_search_test.go +++ b/expr/pg_text_search_test.go @@ -1,6 +1,7 @@ package expr_test import ( + "errors" "fmt" "testing" @@ -11,11 +12,15 @@ import ( // helpers -func pgCtx() *expr.BuildContext { return expr.NewBuildContext(dialect.Postgres) } -func myCtx() *expr.BuildContext { return expr.NewBuildContext(dialect.MySQL) } -func sqliteCtx() *expr.BuildContext { return expr.NewBuildContext(dialect.SQLite) } +func pgCtx() *expr.BuildContext { return expr.NewBuildContext(dialect.Postgres) } -func sql(e expr.Expression) string { return e.ToSQL(pgCtx()) } +func sql(e expr.Expression) string { + rendered, err := e.RenderSQL(pgCtx()) + if err != nil { + panic(err) + } + return rendered +} // ----------------------------------------------------------------------- // StringColumn regex operators @@ -244,7 +249,7 @@ func TestToTsvector_MatchesWithConfig(t *testing.T) { func TestTsRank(t *testing.T) { tsq := expr.PlainToTsquery("grizzle orm") rank := expr.TsRank(ts.ArticlesT.SearchVector, tsq) - got := rank.ToSQL(pgCtx()) + got, _ := rank.RenderSQL(pgCtx()) want := `TS_RANK("articles"."search_vector", plainto_tsquery($1))` if got != want { t.Errorf("TsRank: got %q, want %q", got, want) @@ -254,7 +259,7 @@ func TestTsRank(t *testing.T) { func TestTsRankCd(t *testing.T) { tsq := expr.PlainToTsquery("grizzle orm") rank := expr.TsRankCd(ts.ArticlesT.SearchVector, tsq) - got := rank.ToSQL(pgCtx()) + got, _ := rank.RenderSQL(pgCtx()) want := `TS_RANK_CD("articles"."search_vector", plainto_tsquery($1))` if got != want { t.Errorf("TsRankCd: got %q, want %q", got, want) @@ -264,7 +269,7 @@ func TestTsRankCd(t *testing.T) { func TestTsRank_Desc(t *testing.T) { tsq := expr.PlainToTsquery("grizzle orm") order := expr.TsRank(ts.ArticlesT.SearchVector, tsq).Desc() - got := order.ToSQL(pgCtx()) + got, _ := order.RenderSQL(pgCtx()) want := `TS_RANK("articles"."search_vector", plainto_tsquery($1)) DESC` if got != want { t.Errorf("TsRank.Desc: got %q, want %q", got, want) @@ -327,7 +332,7 @@ func TestWebsearchToTsqueryWithConfig_EmptyConfigStillEmits2ArgForm(t *testing.T func TestFTSArgOrder_TsvectorColumn(t *testing.T) { ctx := pgCtx() e := ts.ArticlesT.SearchVector.MatchesWithConfig("english", "grizzle & orm") - _ = e.ToSQL(ctx) + _, _ = e.RenderSQL(ctx) args := ctx.Args() if len(args) != 2 { t.Fatalf("expected 2 args, got %d", len(args)) @@ -343,7 +348,7 @@ func TestFTSArgOrder_TsvectorColumn(t *testing.T) { func TestFTSArgOrder_ToTsvector(t *testing.T) { ctx := pgCtx() e := expr.ToTsvector(ts.ArticlesT.Body, "english").MatchesPlain("grizzle orm") - _ = e.ToSQL(ctx) + _, _ = e.RenderSQL(ctx) args := ctx.Args() if len(args) != 2 { t.Fatalf("expected 2 args, got %d", len(args)) @@ -359,7 +364,7 @@ func TestFTSArgOrder_ToTsvector(t *testing.T) { func TestFTSArgOrder_ToTsqueryWithConfig(t *testing.T) { ctx := pgCtx() e := expr.ToTsqueryWithConfig("english", "grizzle & orm") - _ = e.ToSQL(ctx) + _, _ = e.RenderSQL(ctx) args := ctx.Args() if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) @@ -375,7 +380,7 @@ func TestFTSArgOrder_ToTsqueryWithConfig(t *testing.T) { func TestFTSArgOrder_PlainToTsqueryWithConfig(t *testing.T) { ctx := pgCtx() e := expr.PlainToTsqueryWithConfig("english", "grizzle orm") - _ = e.ToSQL(ctx) + _, _ = e.RenderSQL(ctx) args := ctx.Args() if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) @@ -391,7 +396,7 @@ func TestFTSArgOrder_PlainToTsqueryWithConfig(t *testing.T) { func TestRegexpArgOrder(t *testing.T) { ctx := pgCtx() e := ts.UsersT.Email.RegexpMatch("^alice") - _ = e.ToSQL(ctx) + _, _ = e.RenderSQL(ctx) args := ctx.Args() if len(args) != 1 { t.Fatalf("expected 1 arg, got %d", len(args)) @@ -408,7 +413,7 @@ func TestRegexpArgOrder(t *testing.T) { func ExampleStringColumn_RegexpMatch() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Email.RegexpMatch("^alice") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."email" ~ $1 } @@ -416,7 +421,7 @@ func ExampleStringColumn_RegexpMatch() { func ExampleStringColumn_RegexpMatchI() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Email.RegexpMatchI("^alice") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."email" ~* $1 } @@ -424,7 +429,7 @@ func ExampleStringColumn_RegexpMatchI() { func ExampleStringColumn_NotRegexpMatch() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Email.NotRegexpMatch("^alice") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."email" !~ $1 } @@ -432,7 +437,7 @@ func ExampleStringColumn_NotRegexpMatch() { func ExampleStringColumn_NotRegexpMatchI() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Email.NotRegexpMatchI("^alice") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."email" !~* $1 } @@ -440,7 +445,7 @@ func ExampleStringColumn_NotRegexpMatchI() { func ExampleTsvectorColumn_Matches() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.ArticlesT.SearchVector.Matches("grizzle & orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "articles"."search_vector" @@ to_tsquery($1) } @@ -448,7 +453,7 @@ func ExampleTsvectorColumn_Matches() { func ExampleTsvectorColumn_MatchesPlain() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.ArticlesT.SearchVector.MatchesPlain("grizzle orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "articles"."search_vector" @@ plainto_tsquery($1) } @@ -456,7 +461,7 @@ func ExampleTsvectorColumn_MatchesPlain() { func ExampleTsvectorColumn_MatchesWebSearch() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.ArticlesT.SearchVector.MatchesWebSearch("grizzle -orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "articles"."search_vector" @@ websearch_to_tsquery($1) } @@ -464,7 +469,7 @@ func ExampleTsvectorColumn_MatchesWebSearch() { func ExampleToTsvector() { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.ToTsvector(ts.ArticlesT.Body, "english").MatchesPlain("grizzle orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // to_tsvector($1, "articles"."body") @@ plainto_tsquery($2) } @@ -473,7 +478,7 @@ func ExampleTsRank() { ctx := expr.NewBuildContext(dialect.Postgres) tsq := expr.PlainToTsquery("grizzle orm") rank := expr.TsRank(ts.ArticlesT.SearchVector, tsq) - fmt.Println(rank.Desc().ToSQL(ctx)) + fmt.Println(mustRender(rank.Desc(), ctx)) // Output: // TS_RANK("articles"."search_vector", plainto_tsquery($1)) DESC } @@ -481,7 +486,7 @@ func ExampleTsRank() { func ExampleStringColumn_NotLike() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Username.NotLike("admin%") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."username" NOT LIKE $1 } @@ -489,7 +494,7 @@ func ExampleStringColumn_NotLike() { func ExampleStringColumn_NotILike() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.UsersT.Username.NotILike("admin%") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "users"."username" NOT ILIKE $1 } @@ -498,7 +503,7 @@ func ExampleTsRankCd() { ctx := expr.NewBuildContext(dialect.Postgres) tsq := expr.PlainToTsquery("grizzle orm") rank := expr.TsRankCd(ts.ArticlesT.SearchVector, tsq) - fmt.Println(rank.ToSQL(ctx)) + fmt.Println(mustRender(rank, ctx)) // Output: // TS_RANK_CD("articles"."search_vector", plainto_tsquery($1)) } @@ -506,7 +511,7 @@ func ExampleTsRankCd() { func ExampleTsvectorColumn_MatchesWithConfig() { ctx := expr.NewBuildContext(dialect.Postgres) e := ts.ArticlesT.SearchVector.MatchesWithConfig("english", "grizzle & orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // "articles"."search_vector" @@ to_tsquery($1, $2) } @@ -514,7 +519,7 @@ func ExampleTsvectorColumn_MatchesWithConfig() { func ExampleToTsquery() { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.ToTsquery("grizzle & orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // to_tsquery($1) } @@ -522,7 +527,7 @@ func ExampleToTsquery() { func ExampleToTsqueryWithConfig() { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.ToTsqueryWithConfig("english", "grizzle & orm") - fmt.Println(e.ToSQL(ctx)) + fmt.Println(mustRender(e, ctx)) // Output: // to_tsquery($1, $2) } @@ -532,7 +537,7 @@ func ExampleToTsqueryWithConfig() { // ----------------------------------------------------------------------- func TestTsvectorExpr_As(t *testing.T) { - got := expr.ToTsvector(ts.ArticlesT.Body).As("tsv").ToSQL(pgCtx()) + got, _ := expr.ToTsvector(ts.ArticlesT.Body).As("tsv").RenderSQL(pgCtx()) want := `to_tsvector("articles"."body") AS "tsv"` if got != want { t.Errorf("TsvectorExpr.As: got %q, want %q", got, want) @@ -554,438 +559,89 @@ func TestTsvectorExpr_ColumnName(t *testing.T) { } // ----------------------------------------------------------------------- -// Non-PostgreSQL dialect behaviour: unsupported operators emit safe fallbacks +// Non-PostgreSQL dialect behaviour // ----------------------------------------------------------------------- -// Per issue #230: pg-only regex and FTS operators must not emit unconditionally. -// On non-PG dialects: -// - Predicate expressions (regexpExpr, ftsMatchExpr, ftsMatchExprOnExpr) → "FALSE" -// - Scalar expressions (tsQueryFnExpr) → "NULL" -// - TsvectorExpr without alias → "NULL"; with .As() set → `NULL AS "alias"` -// -// No args are bound when the fallback is emitted, so ctx.Args() remains empty. - -func TestRegexpMatch_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - e := ts.UsersT.Email.RegexpMatch("^alice") - got := e.ToSQL(ctx) - if got != "FALSE" { - t.Errorf("RegexpMatch on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("RegexpMatch on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestRegexpMatchI_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.UsersT.Email.RegexpMatchI("^alice").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("RegexpMatchI on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("RegexpMatchI on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestNotRegexpMatch_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.UsersT.Email.NotRegexpMatch("^alice").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("NotRegexpMatch on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("NotRegexpMatch on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} -func TestNotRegexpMatchI_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.UsersT.Email.NotRegexpMatchI("^alice").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("NotRegexpMatchI on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("NotRegexpMatchI on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_Matches_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - e := ts.ArticlesT.SearchVector.Matches("grizzle & orm") - got := e.ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.Matches on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.Matches on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesPlain_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesPlain("grizzle orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesPlain on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesPlain on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesWithConfig_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesWithConfig("english", "grizzle & orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesWithConfig on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesPhrase_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesPhrase("fast full text").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesPhrase on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesPhrase on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesWebSearch_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesWebSearch("grizzle -orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesWebSearch on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesWebSearch on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestToTsvector_NonPG_MatchesPlain_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.ToTsvector(ts.ArticlesT.Body).MatchesPlain("grizzle orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("ToTsvector.MatchesPlain on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("ToTsvector.MatchesPlain on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestToTsvector_NonPG_Scalar_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.ToTsvector(ts.ArticlesT.Body).ToSQL(ctx) - if got != "NULL" { - t.Errorf("ToTsvector scalar on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("ToTsvector scalar on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestToTsvector_NonPG_ScalarWithAlias_EmitsNULLWithAlias(t *testing.T) { +// PostgreSQL-only expression families fail closed on unsupported dialects. +// They must not emit executable fallback SQL or leave orphaned arguments. +func TestPostgresOnlyExpressions_NonPG_ReturnUnsupportedFeature(t *testing.T) { cases := []struct { name string - ctx *expr.BuildContext - want string + expr func() expr.Expression }{ - {"mysql", myCtx(), "NULL AS `tsv`"}, - {"sqlite", sqliteCtx(), `NULL AS "tsv"`}, - } - for _, tc := range cases { - got := expr.ToTsvector(ts.ArticlesT.Body).As("tsv").ToSQL(tc.ctx) - if got != tc.want { - t.Errorf("ToTsvector.As on %s: got %q, want %q", tc.name, got, tc.want) - } - if len(tc.ctx.Args()) != 0 { - t.Errorf("ToTsvector.As on %s: expected no args bound, got %v", tc.name, tc.ctx.Args()) - } - } -} - -func TestToTsquery_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.ToTsquery("grizzle & orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("ToTsquery on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("ToTsquery on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestPlainToTsquery_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.PlainToTsquery("grizzle orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("PlainToTsquery on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("PlainToTsquery on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestPhraseToTsquery_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.PhraseToTsquery("fast full text").ToSQL(ctx) - if got != "NULL" { - t.Errorf("PhraseToTsquery on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("PhraseToTsquery on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestWebsearchToTsquery_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.WebsearchToTsquery("grizzle -orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("WebsearchToTsquery on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("WebsearchToTsquery on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestToTsqueryWithConfig_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.ToTsqueryWithConfig("english", "grizzle & orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("ToTsqueryWithConfig on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("ToTsqueryWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestPlainToTsqueryWithConfig_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.PlainToTsqueryWithConfig("english", "grizzle orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("PlainToTsqueryWithConfig on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("PlainToTsqueryWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestPhraseToTsqueryWithConfig_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.PhraseToTsqueryWithConfig("english", "fast full text").ToSQL(ctx) - if got != "NULL" { - t.Errorf("PhraseToTsqueryWithConfig on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("PhraseToTsqueryWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestWebsearchToTsqueryWithConfig_NonPG_EmitsNULL(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := expr.WebsearchToTsqueryWithConfig("english", "grizzle -orm").ToSQL(ctx) - if got != "NULL" { - t.Errorf("WebsearchToTsqueryWithConfig on %s: got %q, want \"NULL\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("WebsearchToTsqueryWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesPlainWithConfig_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesPlainWithConfig("english", "grizzle orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesPlainWithConfig on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesPlainWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesPhraseWithConfig_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesPhraseWithConfig("english", "fast full text").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesPhraseWithConfig on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesPhraseWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -func TestTsvectorColumn_MatchesWebSearchWithConfig_NonPG_EmitsFALSE(t *testing.T) { - for _, name := range []string{"mysql", "sqlite"} { - ctx := myCtx() - if name == "sqlite" { - ctx = sqliteCtx() - } - got := ts.ArticlesT.SearchVector.MatchesWebSearchWithConfig("english", "grizzle -orm").ToSQL(ctx) - if got != "FALSE" { - t.Errorf("TsvectorColumn.MatchesWebSearchWithConfig on %s: got %q, want \"FALSE\"", name, got) - } - if len(ctx.Args()) != 0 { - t.Errorf("TsvectorColumn.MatchesWebSearchWithConfig on %s: expected no args bound, got %v", name, ctx.Args()) - } - } -} - -// TsRank and TsRankCd are built on the generic FuncExpr and do not have their -// own dialect gate. On non-PG dialects the tsquery argument emits NULL (since -// tsQueryFnExpr is gated), producing TS_RANK(col, NULL) — syntactically valid -// but semantically meaningless. Callers should check SupportsFullTextSearch() -// before using TsRank/TsRankCd with non-PG dialects. -func TestTsRank_NonPG_EmitsWithNullArg(t *testing.T) { - cases := []struct { + {"regexp", func() expr.Expression { return ts.UsersT.Email.RegexpMatch("^alice") }}, + {"regexp insensitive", func() expr.Expression { return ts.UsersT.Email.RegexpMatchI("^alice") }}, + {"not regexp", func() expr.Expression { return ts.UsersT.Email.NotRegexpMatch("^alice") }}, + {"not regexp insensitive", func() expr.Expression { return ts.UsersT.Email.NotRegexpMatchI("^alice") }}, + {"tsvector match", func() expr.Expression { return ts.ArticlesT.SearchVector.Matches("grizzle & orm") }}, + {"plain match", func() expr.Expression { return ts.ArticlesT.SearchVector.MatchesPlain("grizzle orm") }}, + {"configured match", func() expr.Expression { return ts.ArticlesT.SearchVector.MatchesWithConfig("english", "grizzle & orm") }}, + {"phrase match", func() expr.Expression { return ts.ArticlesT.SearchVector.MatchesPhrase("fast full text") }}, + {"websearch match", func() expr.Expression { return ts.ArticlesT.SearchVector.MatchesWebSearch("grizzle -orm") }}, + {"computed tsvector match", func() expr.Expression { return expr.ToTsvector(ts.ArticlesT.Body).MatchesPlain("grizzle orm") }}, + {"computed tsvector", func() expr.Expression { return expr.ToTsvector(ts.ArticlesT.Body) }}, + {"tsquery", func() expr.Expression { return expr.ToTsquery("grizzle & orm") }}, + {"plain tsquery", func() expr.Expression { return expr.PlainToTsquery("grizzle orm") }}, + {"phrase tsquery", func() expr.Expression { return expr.PhraseToTsquery("fast full text") }}, + {"websearch tsquery", func() expr.Expression { return expr.WebsearchToTsquery("grizzle -orm") }}, + {"configured tsquery", func() expr.Expression { return expr.ToTsqueryWithConfig("english", "grizzle & orm") }}, + {"configured plain tsquery", func() expr.Expression { return expr.PlainToTsqueryWithConfig("english", "grizzle orm") }}, + {"configured phrase tsquery", func() expr.Expression { return expr.PhraseToTsqueryWithConfig("english", "fast full text") }}, + {"configured websearch tsquery", func() expr.Expression { return expr.WebsearchToTsqueryWithConfig("english", "grizzle -orm") }}, + {"rank", func() expr.Expression { + return expr.TsRank(ts.ArticlesT.SearchVector, expr.PlainToTsquery("grizzle orm")) + }}, + {"rank cd", func() expr.Expression { + return expr.TsRankCd(ts.ArticlesT.SearchVector, expr.PlainToTsquery("grizzle orm")) + }}, + } + + dialects := []struct { name string - ctx *expr.BuildContext - col string // expected quoted column reference + d dialect.Dialect }{ - {"mysql", myCtx(), "`articles`.`search_vector`"}, - {"sqlite", sqliteCtx(), `"articles"."search_vector"`}, + {"mysql", dialect.MySQL}, + {"sqlite", dialect.SQLite}, } for _, tc := range cases { - tsq := expr.PlainToTsquery("grizzle orm") - rank := expr.TsRank(ts.ArticlesT.SearchVector, tsq) - got := rank.ToSQL(tc.ctx) - // The tsquery arg emits NULL on non-PG; no args should be bound. - want := "TS_RANK(" + tc.col + ", NULL)" - if got != want { - t.Errorf("TsRank on %s: got %q, want %q", tc.name, got, want) - } - if len(tc.ctx.Args()) != 0 { - t.Errorf("TsRank on %s: expected no args bound, got %v", tc.name, tc.ctx.Args()) - } - } -} - -func TestTsRankCd_NonPG_EmitsWithNullArg(t *testing.T) { - cases := []struct { + for _, dc := range dialects { + t.Run(tc.name+"/"+dc.name, func(t *testing.T) { + ctx := expr.NewBuildContext(dc.d) + got, err := tc.expr().RenderSQL(ctx) + if got != "" { + t.Errorf("rendered executable SQL on failure: %q", got) + } + if !errors.Is(err, expr.ErrUnsupportedFeature) { + t.Fatalf("error = %v, want ErrUnsupportedFeature", err) + } + if len(ctx.Args()) != 0 { + t.Errorf("orphaned args: %v", ctx.Args()) + } + }) + } + } +} + +func TestPostgresOnlyPredicates_NegationPropagatesUnsupportedFeature(t *testing.T) { + predicates := []struct { name string - ctx *expr.BuildContext - col string + expr expr.Expression }{ - {"mysql", myCtx(), "`articles`.`search_vector`"}, - {"sqlite", sqliteCtx(), `"articles"."search_vector"`}, - } - for _, tc := range cases { - tsq := expr.PlainToTsquery("grizzle orm") - rank := expr.TsRankCd(ts.ArticlesT.SearchVector, tsq) - got := rank.ToSQL(tc.ctx) - want := "TS_RANK_CD(" + tc.col + ", NULL)" - if got != want { - t.Errorf("TsRankCd on %s: got %q, want %q", tc.name, got, want) - } - if len(tc.ctx.Args()) != 0 { - t.Errorf("TsRankCd on %s: expected no args bound, got %v", tc.name, tc.ctx.Args()) + {"regexp", ts.UsersT.Email.RegexpMatch("^alice")}, + {"full text", ts.ArticlesT.SearchVector.MatchesPlain("grizzle orm")}, + } + for _, predicate := range predicates { + for _, d := range []dialect.Dialect{dialect.MySQL, dialect.SQLite} { + t.Run(predicate.name+"/"+d.Name(), func(t *testing.T) { + ctx := expr.NewBuildContext(d) + got, err := expr.Not(predicate.expr).RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrUnsupportedFeature) { + t.Fatalf("got (%q, %v), want empty SQL and ErrUnsupportedFeature", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("negated failure retained args: %v", ctx.Args()) + } + }) } } } diff --git a/expr/window.go b/expr/window.go index 16c3d75..0f46856 100644 --- a/expr/window.go +++ b/expr/window.go @@ -1,9 +1,6 @@ package expr -import ( - "fmt" - "strings" -) +import "strings" // WindowExpr represents a SQL window function call: // @@ -28,55 +25,89 @@ type WindowExpr struct { partitionBy []SelectableColumn // PARTITION BY columns orderBy []OrderExpr // ORDER BY inside the window alias string // optional AS alias + err error // deferred constructor validation error } -// ToSQL renders the window function including the OVER clause and optional alias. -func (w WindowExpr) ToSQL(ctx *BuildContext) string { - var sb strings.Builder - sb.WriteString(w.fn) - sb.WriteString("(") - if w.col != nil { - sb.WriteString(w.col.colRef(ctx)) - // Render optional numeric offset (NTH_VALUE n, LAG/LEAD offset). - if w.offset != nil { - sb.WriteString(", ") - sb.WriteString(ctx.Add(*w.offset)) +// RenderSQL renders the window function including the OVER clause and optional alias. +func (w WindowExpr) RenderSQL(ctx *BuildContext) (string, error) { + if w.err != nil { + return "", w.err + } + if ctx == nil || isNilInterface(ctx.Dialect()) { + return "", NewError(CodeUnsupportedDialect, "render_window", "dialect is required") + } + if !ctx.Dialect().SupportsWindowFunctions() { + return "", NewError(CodeUnsupportedFeature, "render_window", "window functions are not supported by this dialect") + } + return renderAtomically(ctx, func() (string, error) { + if w.fn == "" { + return "", NewError(CodeBuildValidation, "render_window", "window function is empty") } - // Render optional default value for LAG/LEAD (Fix #93 — must use ctx.Add). - if w.hasDefault { - sb.WriteString(", ") - sb.WriteString(ctx.Add(w.defaultVal)) + var sb strings.Builder + sb.WriteString(w.fn) + sb.WriteString("(") + if !isNilInterface(w.col) { + col, err := w.col.colRef(ctx) + if err != nil { + return "", err + } + sb.WriteString(col) + // Render optional numeric offset (NTH_VALUE n, LAG/LEAD offset). + if w.offset != nil { + sb.WriteString(", ") + sb.WriteString(ctx.Add(*w.offset)) + } + // Render optional default value for LAG/LEAD (Fix #93 — must use ctx.Add). + if w.hasDefault { + sb.WriteString(", ") + sb.WriteString(ctx.Add(w.defaultVal)) + } } - } - sb.WriteString(") OVER (") - - var parts []string - if len(w.partitionBy) > 0 { - cols := make([]string, len(w.partitionBy)) - for i, c := range w.partitionBy { - cols[i] = c.colRef(ctx) + sb.WriteString(") OVER (") + + var parts []string + if len(w.partitionBy) > 0 { + cols := make([]string, len(w.partitionBy)) + for i, c := range w.partitionBy { + if isNilInterface(c) { + return "", NewError(CodeBuildValidation, "render_window", "partition column is nil") + } + col, err := c.colRef(ctx) + if err != nil { + return "", err + } + cols[i] = col + } + parts = append(parts, "PARTITION BY "+strings.Join(cols, ", ")) } - parts = append(parts, "PARTITION BY "+strings.Join(cols, ", ")) - } - if len(w.orderBy) > 0 { - orders := make([]string, len(w.orderBy)) - for i, o := range w.orderBy { - orders[i] = o.ToSQL(ctx) + if len(w.orderBy) > 0 { + orders := make([]string, len(w.orderBy)) + for i, o := range w.orderBy { + order, err := o.RenderSQL(ctx) + if err != nil { + return "", err + } + orders[i] = order + } + parts = append(parts, "ORDER BY "+strings.Join(orders, ", ")) } - parts = append(parts, "ORDER BY "+strings.Join(orders, ", ")) - } - sb.WriteString(strings.Join(parts, " ")) - sb.WriteString(")") - - if w.alias != "" { - sb.WriteString(" AS ") - sb.WriteString(ctx.Quote(w.alias)) - } - return sb.String() + sb.WriteString(strings.Join(parts, " ")) + sb.WriteString(")") + + if w.alias != "" { + sb.WriteString(" AS ") + alias, err := ctx.Quote(w.alias) + if err != nil { + return "", err + } + sb.WriteString(alias) + } + return sb.String(), nil + }) } // colRef implements colRefer so WindowExpr can appear in OrderExpr and binary expressions. -func (w WindowExpr) colRef(ctx *BuildContext) string { return w.ToSQL(ctx) } +func (w WindowExpr) colRef(ctx *BuildContext) (string, error) { return w.RenderSQL(ctx) } // ColumnName implements SelectableColumn. Returns the alias if set, otherwise // the lower-case function name. @@ -128,47 +159,68 @@ func Rank() WindowExpr { return WindowExpr{fn: "RANK"} } func DenseRank() WindowExpr { return WindowExpr{fn: "DENSE_RANK"} } // Lead returns a LEAD(col) window expression. -func Lead(col SelectableColumn) WindowExpr { return WindowExpr{fn: "LEAD", col: col} } +func Lead(col SelectableColumn) WindowExpr { return requiredColumnWindow("LEAD", col) } // LeadWithDefault returns a LEAD(col, offset, default) window expression. // The default value is bound as a parameter (Fix #93 — not interpolated directly). func LeadWithDefault(col SelectableColumn, offset int, defaultVal any) WindowExpr { - return WindowExpr{fn: "LEAD", col: col, offset: &offset, defaultVal: defaultVal, hasDefault: true} + w := requiredColumnWindow("LEAD", col) + w.offset = &offset + w.defaultVal = defaultVal + w.hasDefault = true + return w } // Lag returns a LAG(col) window expression. -func Lag(col SelectableColumn) WindowExpr { return WindowExpr{fn: "LAG", col: col} } +func Lag(col SelectableColumn) WindowExpr { return requiredColumnWindow("LAG", col) } // LagWithDefault returns a LAG(col, offset, default) window expression. // The default value is bound as a parameter (Fix #93 — not interpolated directly). func LagWithDefault(col SelectableColumn, offset int, defaultVal any) WindowExpr { - return WindowExpr{fn: "LAG", col: col, offset: &offset, defaultVal: defaultVal, hasDefault: true} + w := requiredColumnWindow("LAG", col) + w.offset = &offset + w.defaultVal = defaultVal + w.hasDefault = true + return w } // FirstValue returns a FIRST_VALUE(col) window expression. -func FirstValue(col SelectableColumn) WindowExpr { return WindowExpr{fn: "FIRST_VALUE", col: col} } +func FirstValue(col SelectableColumn) WindowExpr { return requiredColumnWindow("FIRST_VALUE", col) } // LastValue returns a LAST_VALUE(col) window expression. -func LastValue(col SelectableColumn) WindowExpr { return WindowExpr{fn: "LAST_VALUE", col: col} } +func LastValue(col SelectableColumn) WindowExpr { return requiredColumnWindow("LAST_VALUE", col) } // NthValue returns an NTH_VALUE(col, n) window expression. -// n must be >= 1; panics with a clear message if n < 1 (Fix #99). +// Values below 1 produce a build-validation error when rendered. func NthValue(col SelectableColumn, n int) WindowExpr { + w := requiredColumnWindow("NTH_VALUE", col) + w.offset = &n + if w.err != nil { + return w + } if n < 1 { - panic(fmt.Sprintf("expr.NthValue: n must be >= 1, got %d", n)) + w.err = NewError(CodeBuildValidation, "render_window", "window offset must be positive") } - return WindowExpr{fn: "NTH_VALUE", col: col, offset: &n} + return w } // WinSum returns a SUM(col) window expression (aggregate used as a window function). -func WinSum(col SelectableColumn) WindowExpr { return WindowExpr{fn: "SUM", col: col} } +func WinSum(col SelectableColumn) WindowExpr { return requiredColumnWindow("SUM", col) } // WinAvg returns an AVG(col) window expression (aggregate used as a window function). -func WinAvg(col SelectableColumn) WindowExpr { return WindowExpr{fn: "AVG", col: col} } +func WinAvg(col SelectableColumn) WindowExpr { return requiredColumnWindow("AVG", col) } // WinCount returns a COUNT(*) window expression. func WinCount() WindowExpr { return WindowExpr{fn: "COUNT"} } +func requiredColumnWindow(fn string, col SelectableColumn) WindowExpr { + w := WindowExpr{fn: fn, col: col} + if isNilInterface(col) { + w.err = NewError(CodeBuildValidation, "render_window", "window column is nil") + } + return w +} + // ------------------------------------------------------------------- // Window frame sentinels (Fix #104 — immutable, cannot be mutated) // ------------------------------------------------------------------- diff --git a/expr/window_test.go b/expr/window_test.go index a851a85..0a723e2 100644 --- a/expr/window_test.go +++ b/expr/window_test.go @@ -1,6 +1,7 @@ package expr_test import ( + "errors" "reflect" "strings" "testing" @@ -15,6 +16,10 @@ var ( testScoreCol = expr.IntColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "score"}} ) +type noWindowDialect struct{ dialect.Dialect } + +func (noWindowDialect) SupportsWindowFunctions() bool { return false } + // ------------------------------------------------------------------- // Fix #93 — LeadWithDefault / LagWithDefault bind default as param // ------------------------------------------------------------------- @@ -22,7 +27,7 @@ var ( func TestLeadWithDefault_DefaultBoundAsParam(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) w := expr.LeadWithDefault(testUsernameCol, 1, "N/A").OrderBy(testScoreCol.Asc()) - sql := w.ToSQL(ctx) + sql, _ := w.RenderSQL(ctx) args := ctx.Args() // The default value must appear as a bound parameter, not literal text. @@ -48,11 +53,14 @@ func TestLeadWithDefault_DefaultBoundAsParam(t *testing.T) { func TestLeadWithDefault_NumericDefaultBoundAsParams(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) w := expr.LeadWithDefault(testScoreCol, 1, 0).OrderBy(testScoreCol.Asc()) - sql := w.ToSQL(ctx) + sql, err := w.RenderSQL(ctx) + if err != nil { + t.Fatalf("RenderSQL() error = %v", err) + } wantSQL := `LEAD("users"."score", $1, $2) OVER (ORDER BY "users"."score" ASC)` if sql != wantSQL { - t.Errorf("ToSQL() = %q, want %q", sql, wantSQL) + t.Errorf("RenderSQL() = %q, want %q", sql, wantSQL) } if placeholderCount := strings.Count(sql, "$"); placeholderCount != 2 { t.Errorf("placeholder count = %d, want 2 in SQL %q", placeholderCount, sql) @@ -68,7 +76,7 @@ func TestLeadWithDefault_NumericDefaultBoundAsParams(t *testing.T) { func TestLagWithDefault_DefaultBoundAsParam(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) w := expr.LagWithDefault(testScoreCol, 1, 0).OrderBy(testScoreCol.Asc()) - sql := w.ToSQL(ctx) + sql, _ := w.RenderSQL(ctx) args := ctx.Args() if !strings.Contains(sql, "LAG(") { @@ -90,8 +98,7 @@ func TestLagWithDefault_StringDefault_NotInterpolated(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) // A string with single quotes must be handled safely. w := expr.LagWithDefault(testUsernameCol, 1, "it's fine").OrderBy(testScoreCol.Asc()) - sql := w.ToSQL(ctx) - + sql, _ := w.RenderSQL(ctx) if strings.Contains(sql, "it's fine") { t.Errorf("default string must not be interpolated into SQL, got: %s", sql) } @@ -116,33 +123,83 @@ func TestNthValue_ValidN(t *testing.T) { // Should not panic. ctx := expr.NewBuildContext(dialect.Postgres) w := expr.NthValue(testUsernameCol, 1) - sql := w.ToSQL(ctx) + sql, _ := w.RenderSQL(ctx) if !strings.Contains(sql, "NTH_VALUE(") { t.Errorf("expected NTH_VALUE in SQL, got: %s", sql) } } -func TestNthValue_InvalidN_Panics(t *testing.T) { - defer func() { - r := recover() - if r == nil { - t.Error("expected panic for NthValue(col, 0)") - } - msg, ok := r.(string) - if !ok || !strings.Contains(msg, "n must be >= 1") { - t.Errorf("unexpected panic message: %v", r) - } - }() - expr.NthValue(testUsernameCol, 0) +func TestNthValue_InvalidN_ReturnsBuildError(t *testing.T) { + ctx := expr.NewBuildContext(dialect.Postgres) + got, err := expr.NthValue(testUsernameCol, 0).RenderSQL(ctx) + if got != "" { + t.Errorf("SQL = %q, want empty", got) + } + if !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("error = %v, want ErrBuildValidation", err) + } +} + +func TestNthValue_NegativeN_ReturnsBuildError(t *testing.T) { + ctx := expr.NewBuildContext(dialect.Postgres) + got, err := expr.NthValue(testUsernameCol, -1).RenderSQL(ctx) + if got != "" { + t.Errorf("SQL = %q, want empty", got) + } + if !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("error = %v, want ErrBuildValidation", err) + } +} + +func TestWindowExpr_UnsupportedDialectReturnsError(t *testing.T) { + ctx := expr.NewBuildContext(noWindowDialect{Dialect: dialect.Postgres}) + got, err := expr.RowNumber().RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrUnsupportedFeature) { + t.Fatalf("RenderSQL = (%q, %v), want empty SQL and ErrUnsupportedFeature", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("Args = %v, want no orphaned arguments", ctx.Args()) + } } -func TestNthValue_NegativeN_Panics(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("expected panic for NthValue(col, -1)") +func TestWindowExpr_RequiredColumnsRejectNil(t *testing.T) { + var typedNil *expr.StringColumn + columns := []struct { + name string + col expr.SelectableColumn + }{ + {"plain nil", nil}, + {"typed nil", typedNil}, + } + factories := []struct { + name string + new func(expr.SelectableColumn) expr.WindowExpr + }{ + {"lead", expr.Lead}, + {"lead with default", func(col expr.SelectableColumn) expr.WindowExpr { return expr.LeadWithDefault(col, 1, "default") }}, + {"lag", expr.Lag}, + {"lag with default", func(col expr.SelectableColumn) expr.WindowExpr { return expr.LagWithDefault(col, 1, "default") }}, + {"first value", expr.FirstValue}, + {"last value", expr.LastValue}, + {"nth value", func(col expr.SelectableColumn) expr.WindowExpr { return expr.NthValue(col, 1) }}, + {"sum", expr.WinSum}, + {"avg", expr.WinAvg}, + } + + for _, column := range columns { + for _, factory := range factories { + t.Run(column.name+"/"+factory.name, func(t *testing.T) { + ctx := expr.NewBuildContext(dialect.Postgres) + got, err := factory.new(column.col).RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("RenderSQL = (%q, %v), want empty SQL and ErrBuildValidation", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("Args = %v, want no orphaned arguments", ctx.Args()) + } + }) } - }() - expr.NthValue(testUsernameCol, -1) + } } // ------------------------------------------------------------------- @@ -182,22 +239,22 @@ func TestWindowFrameBound_Immutable(t *testing.T) { // Fix #131 — AliasedCol does not emit AS in GROUP BY / ORDER BY // ------------------------------------------------------------------- -func TestAliasedCol_ToSQL_EmitsAlias(t *testing.T) { +func TestAliasedCol_RenderSQL_EmitsAlias(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) col := expr.ColAs(testUsernameCol, "uname") - got := col.ToSQL(ctx) + got, _ := col.RenderSQL(ctx) want := `"users"."username" AS "uname"` if got != want { - t.Errorf("ToSQL: got %q, want %q", got, want) + t.Errorf("RenderSQL: got %q, want %q", got, want) } } func TestAliasedCol_colRef_NoAlias(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) col := expr.ColAs(testUsernameCol, "uname") - // OrderExpr.ToSQL calls colRef internally — should not include the alias. + // OrderExpr.RenderSQL calls colRef internally — should not include the alias. orderExpr := col.Asc() - got := orderExpr.ToSQL(ctx) + got, _ := orderExpr.RenderSQL(ctx) // Check for " AS " with surrounding spaces, not just "AS" (which appears in "ASC"). if strings.Contains(got, " AS ") { t.Errorf("ORDER BY must not include AS alias clause, got: %s", got) @@ -214,7 +271,7 @@ func TestAliasedCol_colRef_NoAlias(t *testing.T) { func TestRawArgs_MatchingPlaceholders(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.RawArgs("col = $? AND other = $?", 42, "hello") - got := e.ToSQL(ctx) + got, _ := e.RenderSQL(ctx) want := "col = $1 AND other = $2" if got != want { t.Errorf("got %q, want %q", got, want) @@ -225,40 +282,32 @@ func TestRawArgs_MatchingPlaceholders(t *testing.T) { } } -func TestRawArgs_TooFewArgs_Panics(t *testing.T) { - defer func() { - r := recover() - if r == nil { - t.Error("expected panic for too few args (2 placeholders, 1 arg)") - } - msg, ok := r.(string) - if !ok || !strings.Contains(msg, "placeholder count") { - t.Errorf("unexpected panic message: %v", r) - } - }() +func TestRawArgs_TooFewArgs_ReturnsBuildError(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - expr.RawArgs("col = $? AND other = $?", 42).ToSQL(ctx) // 2 placeholders, 1 arg + got, err := expr.RawArgs("col = $? AND other = $?", 42).RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("got (%q, %v), want empty SQL and ErrBuildValidation", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("orphaned args: %v", ctx.Args()) + } } -func TestRawArgs_TooManyArgs_Panics(t *testing.T) { - defer func() { - r := recover() - if r == nil { - t.Error("expected panic for too many args (1 placeholder, 2 args)") - } - msg, ok := r.(string) - if !ok || !strings.Contains(msg, "placeholder count") { - t.Errorf("unexpected panic message: %v", r) - } - }() +func TestRawArgs_TooManyArgs_ReturnsBuildError(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - expr.RawArgs("col = $?", 42, "extra").ToSQL(ctx) // 1 placeholder, 2 args + got, err := expr.RawArgs("col = $?", 42, "extra").RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("got (%q, %v), want empty SQL and ErrBuildValidation", got, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("orphaned args: %v", ctx.Args()) + } } func TestRawArgs_NoPlaceholders_NoArgs(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) e := expr.RawArgs("TRUE") - got := e.ToSQL(ctx) + got, _ := e.RenderSQL(ctx) if got != "TRUE" { t.Errorf("got %q, want %q", got, "TRUE") } diff --git a/query/build_errors_test.go b/query/build_errors_test.go new file mode 100644 index 0000000..c289419 --- /dev/null +++ b/query/build_errors_test.go @@ -0,0 +1,222 @@ +package query_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/sofired/grizzle/dialect" + "github.com/sofired/grizzle/expr" + "github.com/sofired/grizzle/query" +) + +type unsafeTable string + +func (t unsafeTable) GrizTableName() string { return string(t) } +func (t unsafeTable) GrizTableAlias() string { return string(t) } + +type nilTestDialect struct{ dialect.Dialect } + +type leakingExpression struct{} + +func (leakingExpression) RenderSQL(ctx *expr.BuildContext) (string, error) { + _ = ctx.Add("sensitive-value") + return "partial unsafe SQL", fmt.Errorf("driver detail: secret-table") +} + +func TestBuild_RejectsNilAndTypedNilDialect(t *testing.T) { + var typedNil *nilTestDialect + for _, d := range []dialect.Dialect{nil, typedNil} { + sql, args, err := query.Select().Build(d) + if sql != "" || args != nil { + t.Fatalf("got SQL %q args %v on invalid dialect", sql, args) + } + if !errors.Is(err, query.ErrUnsupportedDialect) { + t.Fatalf("error = %v, want ErrUnsupportedDialect", err) + } + } +} + +func TestBuild_InvalidIdentifierFailsClosedAndRedacted(t *testing.T) { + const unsafe = "users\nsecret-table" + sql, args, err := query.Select().From(unsafeTable(unsafe)).Build(dialect.Postgres) + if sql != "" || args != nil { + t.Fatalf("got SQL %q args %v on invalid identifier", sql, args) + } + if !errors.Is(err, query.ErrInvalidIdentifier) { + t.Fatalf("error = %v, want ErrInvalidIdentifier", err) + } + if strings.Contains(err.Error(), unsafe) || strings.Contains(err.Error(), "secret-table") { + t.Fatalf("error leaked unsafe identifier: %q", err) + } +} + +func TestBuild_ExpressionErrorFailsClosedWithoutArguments(t *testing.T) { + col := expr.StringColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "name"}} + b := query.Select().From(unsafeTable("users")).Where(expr.And( + col.EQ("sensitive-value"), + expr.RawArgs("x = $? AND y = $?", 1), + )) + sql, args, err := b.Build(dialect.Postgres) + if sql != "" || args != nil { + t.Fatalf("got SQL %q args %v on failed render", sql, args) + } + if !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("error = %v, want ErrBuildValidation", err) + } + if strings.Contains(err.Error(), "sensitive-value") || strings.Contains(err.Error(), "x =") { + t.Fatalf("error leaked SQL or value: %q", err) + } +} + +func TestBuild_ExternalExpressionErrorIsNormalizedAndRedacted(t *testing.T) { + sql, args, err := query.Select().Where(leakingExpression{}).Build(dialect.Postgres) + if sql != "" || args != nil { + t.Fatalf("got SQL %q args %v on failed external render", sql, args) + } + if !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("error = %v, want ErrBuildValidation", err) + } + if strings.Contains(err.Error(), "secret-table") || strings.Contains(err.Error(), "partial unsafe SQL") { + t.Fatalf("error leaked external details: %q", err) + } +} + +func TestBuild_UnsupportedExpressionFailsClosedWithoutArguments(t *testing.T) { + col := expr.StringColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "name"}} + b := query.Select().From(unsafeTable("users")).Where(expr.Not(col.RegexpMatch("secret-pattern"))) + sql, args, err := b.Build(dialect.MySQL) + if sql != "" || args != nil { + t.Fatalf("got SQL %q args %v on unsupported feature", sql, args) + } + if !errors.Is(err, query.ErrUnsupportedFeature) { + t.Fatalf("error = %v, want ErrUnsupportedFeature", err) + } +} + +func TestBuild_RejectsTypedNilWherePredicates(t *testing.T) { + var typedNil *leakingExpression + table := unsafeTable("users") + builders := []query.Builder{ + query.Select().From(table).Where(typedNil), + query.Update(table).Set("name", "alice").Where(typedNil), + query.DeleteFrom(table).Where(typedNil), + } + for _, b := range builders { + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) + } +} + +func TestBuild_LogicalCombinatorsPreserveTypedNilPredicates(t *testing.T) { + var typedNil *leakingExpression + table := unsafeTable("users") + builders := []query.Builder{ + query.Select().From(table).Where(expr.And(typedNil, nil)), + query.Select().From(table).Where(expr.Or(nil, typedNil)), + query.Select().From(table).Where(expr.Not(typedNil)), + query.Update(table).Set("name", "alice").Where(typedNil).And(nil), + query.DeleteFrom(table).Where(typedNil).And(nil), + } + for _, b := range builders { + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) + } +} + +func TestBuild_CaseExpressionsRejectTypedNilFallbacks(t *testing.T) { + var typedNil *leakingExpression + username := expr.StringColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "username"}} + expressions := []expr.Expression{ + expr.Case().When(expr.Raw("TRUE"), expr.Lit("matched")).Else(typedNil), + expr.SimpleCase(username).WhenVal("alice", expr.Lit("matched")).Else(typedNil), + } + for _, expression := range expressions { + ctx := expr.NewBuildContext(dialect.Postgres) + sql, err := expression.RenderSQL(ctx) + if sql != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("RenderSQL = (%q, %v), want empty SQL and ErrBuildValidation", sql, err) + } + if len(ctx.Args()) != 0 { + t.Fatalf("Args = %v, want no orphaned arguments", ctx.Args()) + } + } +} + +func TestBuild_AllowsIntentionalNilWherePredicates(t *testing.T) { + table := unsafeTable("users") + builders := []query.Builder{ + query.Select().From(table).Where(nil), + query.Update(table).Set("name", "alice").Where(nil), + query.DeleteFrom(table).Where(nil), + } + for _, b := range builders { + sql, _, err := b.Build(dialect.Postgres) + if err != nil || sql == "" { + t.Fatalf("intentional nil WHERE build = (%q, %v), want non-empty SQL and nil error", sql, err) + } + } +} + +func TestBuild_RejectsNilJoinPredicates(t *testing.T) { + var typedNil *leakingExpression + for _, predicate := range []expr.Expression{nil, typedNil} { + builders := []query.Builder{ + query.Select().From(unsafeTable("users")).InnerJoin(unsafeTable("realms"), predicate), + query.Select().From(unsafeTable("users")).LeftJoin(unsafeTable("realms"), predicate), + query.Select().From(unsafeTable("users")).RightJoin(unsafeTable("realms"), predicate), + query.Select().From(unsafeTable("users")).FullJoin(unsafeTable("realms"), predicate), + } + for _, b := range builders { + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) + } + } +} + +func TestBuild_InvalidLockMetadataReturnsBuildValidation(t *testing.T) { + base := query.Select().From(unsafeTable("users")) + cases := []query.Builder{ + base.For(query.LockStrength("FOR UPDATE; DROP TABLE users")), + base.For(query.LockForUpdate, query.LockOption("INVALID")), + base.For(query.LockForUpdate, query.NoWait, query.NoWait), + base.Of(), + base.ForUpdate().Of(unsafeTable("other")), + } + for _, b := range cases { + sql, args, err := b.Build(dialect.Postgres) + if sql != "" || args != nil || !errors.Is(err, query.ErrBuildValidation) { + t.Fatalf("got (%q, %v, %v), want closed build-validation failure", sql, args, err) + } + } +} + +func TestBuild_ConflictValidationFailsClosed(t *testing.T) { + table := unsafeTable("users") + + postgresMissingTarget := query.InsertInto(table). + Values(struct { + Name string `db:"name"` + }{Name: "alice"}). + DoUpdateSet("name", "bob") + assertBuildError(t, postgresMissingTarget, dialect.Postgres, query.ErrBuildValidation) + + mysqlTarget := query.InsertInto(table). + Values(struct { + Name string `db:"name"` + }{Name: "alice"}). + OnConflict("name"). + DoUpdateSet("name", "bob") + assertBuildError(t, mysqlTarget, dialect.MySQL, query.ErrUnsupportedFeature) +} + +func TestBuild_NegativePaginationReturnsBuildValidation(t *testing.T) { + table := unsafeTable("users") + cases := []query.Builder{ + query.Select().From(table).Limit(-1), + query.Select().From(table).Offset(-1), + query.Select().Union(query.Select()).Limit(-1), + query.Select().Union(query.Select()).Offset(-1), + } + for _, b := range cases { + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) + } +} diff --git a/query/delete.go b/query/delete.go index 8a79b77..0a3c0de 100644 --- a/query/delete.go +++ b/query/delete.go @@ -41,8 +41,8 @@ func (b *DeleteBuilder) Returning(cols ...expr.SelectableColumn) *DeleteBuilder } // Limit sets a row limit on the DELETE (MySQL / SQLite only). -// PostgreSQL does not support LIMIT on DELETE; this is silently ignored for -// dialects that do not support it. +// Build returns ErrUnsupportedFeature when the selected dialect does not +// support the clause. func (b *DeleteBuilder) Limit(n int) *DeleteBuilder { cp := *b cp.limit = n @@ -50,28 +50,55 @@ func (b *DeleteBuilder) Limit(n int) *DeleteBuilder { } // Build renders the DELETE statement. -func (b *DeleteBuilder) Build(d dialect.Dialect) (string, []any) { - ctx := expr.NewBuildContext(d) +func (b *DeleteBuilder) Build(d dialect.Dialect) (string, []any, error) { + ctx, err := newBuildContext(d) + if err != nil { + return buildFailure("build_delete", err) + } + if b == nil { + return buildFailure("build_delete", NewError(CodeBuildValidation, "build_delete", "delete builder is nil")) + } + if b.limit < 0 { + return buildFailure("build_delete", NewError(CodeBuildValidation, "build_delete", "delete limit must not be negative")) + } var sb strings.Builder sb.WriteString("DELETE FROM ") - sb.WriteString(ctx.Quote(b.table.GrizTableName())) + table, err := quoteTableSource(ctx, b.table) + if err != nil { + return buildFailure("build_delete", err) + } + sb.WriteString(table) - sb.WriteString(buildWhere(ctx, b.where)) + where, err := buildWhere(ctx, b.where) + if err != nil { + return buildFailure("build_delete", err) + } + sb.WriteString(where) - if b.limit > 0 && d.SupportsLimitOnMutate() { - fmt.Fprintf(&sb, " LIMIT %d", b.limit) + if b.limit > 0 { + if !d.SupportsLimitOnMutate() { + return buildFailure("build_delete", NewError(CodeUnsupportedFeature, "build_delete", "delete limit is not supported by this dialect")) + } + _, _ = fmt.Fprintf(&sb, " LIMIT %d", b.limit) } - if len(b.returning) > 0 && d.SupportsReturning() { + if len(b.returning) > 0 { + if !d.SupportsReturning() { + return buildFailure("build_delete", NewError(CodeUnsupportedFeature, "build_delete", "returning is not supported by this dialect")) + } sb.WriteString(" RETURNING ") for i, c := range b.returning { if i > 0 { sb.WriteString(", ") } - sb.WriteString(selectColSQL(ctx, c)) + column, err := selectColSQL(ctx, c) + if err != nil { + return buildFailure("build_delete", err) + } + sb.WriteString(column) } } - return sb.String(), ctx.Args() + return sb.String(), ctx.Args(), nil } diff --git a/query/errors.go b/query/errors.go new file mode 100644 index 0000000..12bcdfa --- /dev/null +++ b/query/errors.go @@ -0,0 +1,65 @@ +package query + +import "github.com/sofired/grizzle/expr" + +// ErrorCode is the stable programmatic classification for query build and +// execution failures. +type ErrorCode = expr.ErrorCode + +// Error is the shared redacted Grizzle error shape. +type Error = expr.Error + +const ( + CodeUnsupportedFeature = expr.CodeUnsupportedFeature + CodeUnsupportedDialect = expr.CodeUnsupportedDialect + CodeInvalidIdentifier = expr.CodeInvalidIdentifier + CodePreparedNotReady = expr.CodePreparedNotReady + CodeRegistryClosed = expr.CodeRegistryClosed + CodeMissingParam = expr.CodeMissingParam + CodeInvalidParamType = expr.CodeInvalidParamType + CodeInvalidParamValue = expr.CodeInvalidParamValue + CodeParamEncode = expr.CodeParamEncode + CodeInvalidResultKind = expr.CodeInvalidResultKind + CodeDuplicateRegistry = expr.CodeDuplicateRegistry + CodePreparedTxMismatch = expr.CodePreparedTxMismatch + CodeInvalidReceiver = expr.CodeInvalidReceiver + CodeBuildValidation = expr.CodeBuildValidation + CodeNotFound = expr.CodeNotFound + CodeTooManyRows = expr.CodeTooManyRows + CodeInvalidRows = expr.CodeInvalidRows + CodeScanDecode = expr.CodeScanDecode + CodeTransactionBegin = expr.CodeTransactionBegin + CodeTransactionCommit = expr.CodeTransactionCommit + CodeTransactionRollback = expr.CodeTransactionRollback + CodeTransactionCallback = expr.CodeTransactionCallback +) + +var ( + ErrUnsupportedFeature = expr.ErrUnsupportedFeature + ErrUnsupportedDialect = expr.ErrUnsupportedDialect + ErrInvalidIdentifier = expr.ErrInvalidIdentifier + ErrPreparedNotReady = expr.ErrPreparedNotReady + ErrRegistryClosed = expr.ErrRegistryClosed + ErrMissingParam = expr.ErrMissingParam + ErrInvalidParamType = expr.ErrInvalidParamType + ErrInvalidParamValue = expr.ErrInvalidParamValue + ErrParamEncode = expr.ErrParamEncode + ErrInvalidResultKind = expr.ErrInvalidResultKind + ErrDuplicateRegistry = expr.ErrDuplicateRegistry + ErrPreparedTxMismatch = expr.ErrPreparedTxMismatch + ErrInvalidReceiver = expr.ErrInvalidReceiver + ErrBuildValidation = expr.ErrBuildValidation + ErrNotFound = expr.ErrNotFound + ErrTooManyRows = expr.ErrTooManyRows + ErrInvalidRows = expr.ErrInvalidRows + ErrScanDecode = expr.ErrScanDecode + ErrTransactionBegin = expr.ErrTransactionBegin + ErrTransactionCommit = expr.ErrTransactionCommit + ErrTransactionRollback = expr.ErrTransactionRollback + ErrTransactionCallback = expr.ErrTransactionCallback +) + +// NewError returns a stable, redacted query error. +func NewError(code ErrorCode, op, message string) *Error { + return expr.NewError(code, op, message) +} diff --git a/query/example_preload_test.go b/query/example_preload_test.go index 0927389..ea485a7 100644 --- a/query/example_preload_test.go +++ b/query/example_preload_test.go @@ -18,11 +18,14 @@ func ExamplePreloadUUIDs() { uuid.MustParse("00000000-0000-0000-0000-000000000001"), uuid.MustParse("00000000-0000-0000-0000-000000000002"), } - sql, _ := query.PreloadUUIDs( + sql, _, err := query.PreloadUUIDs( query.Select().From(ts.RealmsT), ts.RealmsT.ID, ids, ).Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // SELECT * FROM "realms" WHERE "realms"."id" IN ($1, $2) diff --git a/query/example_test.go b/query/example_test.go index abcb135..2b88cd8 100644 --- a/query/example_test.go +++ b/query/example_test.go @@ -13,12 +13,15 @@ import ( // ExampleSelect demonstrates a basic SELECT with WHERE, ORDER BY, and LIMIT. func ExampleSelect() { - sql, _ := query.Select(ts.UsersT.ID, ts.UsersT.Username, ts.UsersT.Email). + sql, _, err := query.Select(ts.UsersT.ID, ts.UsersT.Username, ts.UsersT.Email). From(ts.UsersT). Where(ts.UsersT.DeletedAt.IsNull()). OrderBy(ts.UsersT.Username.Asc()). Limit(20). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // SELECT "users"."id", "users"."username", "users"."email" FROM "users" WHERE "users"."deleted_at" IS NULL ORDER BY "users"."username" ASC LIMIT 20 @@ -27,11 +30,14 @@ func ExampleSelect() { // ExampleSelect_join demonstrates an INNER JOIN using a pre-declared RelationDef. // JoinRel reuses the ON condition encoded in the relation — no repetition needed. func ExampleSelect_join() { - sql, _ := query.Select(ts.UsersT.ID, ts.UsersT.Username, ts.RealmsT.Name). + sql, _, err := query.Select(ts.UsersT.ID, ts.UsersT.Username, ts.RealmsT.Name). From(ts.UsersT). InnerJoinRel(ts.UserRealm). Where(ts.RealmsT.Enabled.IsTrue()). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // SELECT "users"."id", "users"."username", "realms"."name" FROM "users" INNER JOIN "realms" ON "realms"."id" = "users"."realm_id" WHERE "realms"."enabled" = $1 @@ -39,13 +45,16 @@ func ExampleSelect_join() { // ExampleSelect_aggregate demonstrates COUNT with GROUP BY, HAVING, and ORDER BY. func ExampleSelect_aggregate() { - sql, _ := query.Select(ts.UsersT.RealmID, expr.Count().As("cnt")). + sql, _, err := query.Select(ts.UsersT.RealmID, expr.Count().As("cnt")). From(ts.UsersT). Where(ts.UsersT.DeletedAt.IsNull()). GroupBy(ts.UsersT.RealmID). Having(expr.Count().GT(0)). OrderBy(expr.Count().Desc()). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // SELECT "users"."realm_id", COUNT(*) AS "cnt" FROM "users" WHERE "users"."deleted_at" IS NULL GROUP BY "users"."realm_id" HAVING COUNT(*) > $1 ORDER BY COUNT(*) DESC @@ -53,7 +62,7 @@ func ExampleSelect_aggregate() { // ExampleSelect_windowFunction demonstrates ROW_NUMBER() with PARTITION BY and ORDER BY. func ExampleSelect_windowFunction() { - sql, _ := query.Select( + sql, _, err := query.Select( ts.UsersT.ID, ts.UsersT.Username, expr.RowNumber(). @@ -61,6 +70,9 @@ func ExampleSelect_windowFunction() { OrderBy(ts.UsersT.Username.Asc()). As("rn"), ).From(ts.UsersT).Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // SELECT "users"."id", "users"."username", ROW_NUMBER() OVER (PARTITION BY "users"."realm_id" ORDER BY "users"."username" ASC) AS "rn" FROM "users" @@ -74,10 +86,13 @@ func ExampleInsertInto() { Username: "alice", // Email, Enabled, Attributes are nil/omitempty — omitted from INSERT } - sql, args := query.InsertInto(ts.UsersT). + sql, args, err := query.InsertInto(ts.UsersT). Values(row). Returning(ts.UsersT.ID). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) fmt.Println(len(args), "bound args") // Output: @@ -91,11 +106,14 @@ func ExampleInsertInto_upsert() { RealmID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), Username: "alice", } - sql, _ := query.InsertInto(ts.UsersT). + sql, _, err := query.InsertInto(ts.UsersT). Values(row). OnConflict("realm_id", "username"). DoUpdateSetExcluded("email", "enabled"). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // INSERT INTO "users" ("realm_id", "username") VALUES ($1, $2) ON CONFLICT ("realm_id", "username") DO UPDATE SET "email" = EXCLUDED."email", "enabled" = EXCLUDED."enabled" @@ -105,11 +123,14 @@ func ExampleInsertInto_upsert() { // Only non-nil pointer fields in the struct are included in the SET clause. func ExampleUpdate() { enabled := true - sql, _ := query.Update(ts.UsersT). + sql, _, err := query.Update(ts.UsersT). SetStruct(ts.UserUpdate{Enabled: &enabled}). Where(ts.UsersT.ID.EQ(uuid.MustParse("00000000-0000-0000-0000-000000000001"))). Returning(ts.UsersT.UpdatedAt). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // UPDATE "users" SET "enabled" = $1 WHERE "users"."id" = $2 RETURNING "users"."updated_at" @@ -117,9 +138,12 @@ func ExampleUpdate() { // ExampleDeleteFrom demonstrates DELETE FROM with a WHERE clause. func ExampleDeleteFrom() { - sql, _ := query.DeleteFrom(ts.UsersT). + sql, _, err := query.DeleteFrom(ts.UsersT). Where(ts.UsersT.ID.EQ(uuid.MustParse("00000000-0000-0000-0000-000000000001"))). Build(dialect.Postgres) + if err != nil { + panic(err) + } fmt.Println(sql) // Output: // DELETE FROM "users" WHERE "users"."id" = $1 diff --git a/query/insert.go b/query/insert.go index 1e31eef..33a17c8 100644 --- a/query/insert.go +++ b/query/insert.go @@ -1,6 +1,7 @@ package query import ( + "fmt" "reflect" "strings" @@ -15,7 +16,8 @@ type InsertBuilder struct { rows [][]any returning []expr.SelectableColumn upsert *upsertClause - ignoreConflict bool // emit INSERT IGNORE / INSERT OR IGNORE + ignoreConflict bool // emit dialect-specific no-op conflict handling + buildErr error } // upsertClause holds the ON CONFLICT … DO … specification. @@ -23,6 +25,7 @@ type upsertClause struct { // conflict target — exactly one of these is set conflictCols []string // ON CONFLICT (col1, col2) conflictConstraint string // ON CONFLICT ON CONSTRAINT name + conflictTargetSet bool // conflict action — exactly one is set doNothing bool // DO NOTHING @@ -42,10 +45,21 @@ func InsertInto(t TableSource) *InsertBuilder { // // For inserting multiple rows, call Values repeatedly or use ValueSlice. func (b *InsertBuilder) Values(row any) *InsertBuilder { - cols, vals := structToColVals(row) + cols, vals, err := structToColVals(row) cp := *b + if err != nil { + if cp.buildErr == nil { + cp.buildErr = err + } + return &cp + } if len(cp.colNames) == 0 { cp.colNames = cols + } else if !equalStrings(cp.colNames, cols) { + if cp.buildErr == nil { + cp.buildErr = fmt.Errorf("insert rows have inconsistent columns") + } + return &cp } cp.rows = append(append([][]any(nil), cp.rows...), vals) return &cp @@ -53,15 +67,45 @@ func (b *InsertBuilder) Values(row any) *InsertBuilder { // ValueSlice accepts a slice of structs and adds a row for each element. func (b *InsertBuilder) ValueSlice(rows any) *InsertBuilder { + cp := *b + cp.rows = append([][]any(nil), b.rows...) + if rows == nil { + if cp.buildErr == nil { + cp.buildErr = fmt.Errorf("insert row slice is nil") + } + return &cp + } rv := reflect.ValueOf(rows) if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + if cp.buildErr == nil { + cp.buildErr = fmt.Errorf("insert row slice is nil") + } + return &cp + } rv = rv.Elem() } - cp := *b + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + if cp.buildErr == nil { + cp.buildErr = fmt.Errorf("insert rows must be a slice or array") + } + return &cp + } for i := 0; i < rv.Len(); i++ { - cols, vals := structToColVals(rv.Index(i).Interface()) + cols, vals, err := structToColVals(rv.Index(i).Interface()) + if err != nil { + if cp.buildErr == nil { + cp.buildErr = err + } + return &cp + } if len(cp.colNames) == 0 { cp.colNames = cols + } else if !equalStrings(cp.colNames, cols) { + if cp.buildErr == nil { + cp.buildErr = fmt.Errorf("insert rows have inconsistent columns") + } + return &cp } cp.rows = append(cp.rows, vals) } @@ -79,6 +123,7 @@ func (b *InsertBuilder) OnConflict(cols ...string) *InsertBuilder { u := b.upsertCopy() u.conflictCols = cols u.conflictConstraint = "" + u.conflictTargetSet = true cp.upsert = u return &cp } @@ -92,6 +137,7 @@ func (b *InsertBuilder) OnConflictConstraint(name string) *InsertBuilder { u := b.upsertCopy() u.conflictConstraint = name u.conflictCols = nil + u.conflictTargetSet = true cp.upsert = u return &cp } @@ -137,24 +183,15 @@ func (b *InsertBuilder) DoUpdateSetExcluded(cols ...string) *InsertBuilder { // DoUpdateSetStruct extracts non-nil db-tagged fields and adds them to the // DO UPDATE SET clause as explicit col = val assignments. Nil pointer fields // are skipped (same semantics as UpdateBuilder.SetStruct). -// If row is nil, a nil pointer, or not a struct, the conflict action falls back -// to DO NOTHING to avoid emitting an invalid empty SET list. A valid struct -// whose pointer fields are all nil adds no new assignments but does not clear -// any assignments already accumulated via DoUpdateSet or DoUpdateSetExcluded; -// the defense-in-depth guard in buildOnConflict / buildOnDuplicateKey handles -// the case where the final merged set is still empty. +// Invalid inputs are retained as build-validation errors and returned by Build. func (b *InsertBuilder) DoUpdateSetStruct(row any) *InsertBuilder { cols, vals, err := structSetsForUpdate(row) cp := *b u := b.upsertCopy() if err != nil { - // Invalid input (nil, nil pointer, non-struct): fall back to DO NOTHING - // rather than emitting a syntactically invalid DO UPDATE SET with no - // assignments. - u.doNothing = true - u.sets = nil - u.excluded = nil - cp.upsert = u + if cp.buildErr == nil { + cp.buildErr = err + } return &cp } u.doNothing = false @@ -179,9 +216,7 @@ func (b *InsertBuilder) upsertCopy() *upsertClause { // // Dialect behaviour: // - MySQL: emits INSERT IGNORE INTO … -// - SQLite: emits INSERT OR IGNORE INTO … -// - PostgreSQL: no direct equivalent; this flag is silently ignored. -// Use OnConflict(cols).DoNothing() for PostgreSQL instead. +// - PostgreSQL / SQLite: emits ON CONFLICT DO NOTHING func (b *InsertBuilder) IgnoreConflicts() *InsertBuilder { cp := *b cp.ignoreConflict = true @@ -196,22 +231,46 @@ func (b *InsertBuilder) Returning(cols ...expr.SelectableColumn) *InsertBuilder } // Build renders the INSERT statement. -func (b *InsertBuilder) Build(d dialect.Dialect) (string, []any) { - ctx := expr.NewBuildContext(d) +func (b *InsertBuilder) Build(d dialect.Dialect) (string, []any, error) { + ctx, err := newBuildContext(d) + if err != nil { + return buildFailure("build_insert", err) + } + if b == nil { + return buildFailure("build_insert", NewError(CodeBuildValidation, "build_insert", "insert builder is nil")) + } + if b.buildErr != nil { + return buildFailure("build_insert", b.buildErr) + } + if len(b.colNames) == 0 || len(b.rows) == 0 { + return buildFailure("build_insert", NewError(CodeBuildValidation, "build_insert", "insert contains no values")) + } + if b.ignoreConflict && b.upsert != nil { + return buildFailure("build_insert", NewError(CodeBuildValidation, "build_insert", "ignore conflicts cannot be combined with an upsert clause")) + } var sb strings.Builder // Choose INSERT keyword based on ignore flag and dialect support. if b.ignoreConflict { - if clause := d.InsertIgnoreClause(); clause != "" { + if !d.SupportsIgnoreConflicts() { + return buildFailure("build_insert", NewError(CodeUnsupportedFeature, "build_insert", "ignore conflicts is not supported by this dialect")) + } + if d.UpsertStyle() == dialect.UpsertOnConflict { + sb.WriteString("INSERT INTO ") + } else if clause := d.InsertIgnoreClause(); clause != "" { sb.WriteString(clause) sb.WriteString(" INTO ") } else { - sb.WriteString("INSERT INTO ") + return buildFailure("build_insert", NewError(CodeUnsupportedFeature, "build_insert", "ignore conflicts is not supported by this dialect")) } } else { sb.WriteString("INSERT INTO ") } - sb.WriteString(ctx.Quote(b.table.GrizTableName())) + table, err := quoteTableSource(ctx, b.table) + if err != nil { + return buildFailure("build_insert", err) + } + sb.WriteString(table) // Column list sb.WriteString(" (") @@ -219,13 +278,20 @@ func (b *InsertBuilder) Build(d dialect.Dialect) (string, []any) { if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(c)) + column, err := ctx.Quote(c) + if err != nil { + return buildFailure("build_insert", err) + } + sb.WriteString(column) } sb.WriteString(")") // VALUES sb.WriteString(" VALUES ") for ri, row := range b.rows { + if len(row) != len(b.colNames) { + return buildFailure("build_insert", NewError(CodeBuildValidation, "build_insert", "insert row does not match column count")) + } if ri > 0 { sb.WriteString(", ") } @@ -239,29 +305,47 @@ func (b *InsertBuilder) Build(d dialect.Dialect) (string, []any) { sb.WriteString(")") } + if b.ignoreConflict && d.UpsertStyle() == dialect.UpsertOnConflict { + if err := buildOnConflict(&sb, ctx, &upsertClause{doNothing: true}); err != nil { + return buildFailure("build_insert", err) + } + } + // Upsert clause — dialect-specific if b.upsert != nil { switch d.UpsertStyle() { case dialect.UpsertOnConflict: - buildOnConflict(&sb, ctx, b.upsert) + if err := buildOnConflict(&sb, ctx, b.upsert); err != nil { + return buildFailure("build_insert", err) + } case dialect.UpsertDuplicateKey: - buildOnDuplicateKey(&sb, ctx, b.upsert) - // UpsertNone: silently drop the clause + if err := buildOnDuplicateKey(&sb, ctx, b.upsert); err != nil { + return buildFailure("build_insert", err) + } + default: + return buildFailure("build_insert", NewError(CodeUnsupportedFeature, "build_insert", "upsert is not supported by this dialect")) } } // RETURNING — only for dialects that support it - if len(b.returning) > 0 && d.SupportsReturning() { + if len(b.returning) > 0 { + if !d.SupportsReturning() { + return buildFailure("build_insert", NewError(CodeUnsupportedFeature, "build_insert", "returning is not supported by this dialect")) + } sb.WriteString(" RETURNING ") for i, c := range b.returning { if i > 0 { sb.WriteString(", ") } - sb.WriteString(selectColSQL(ctx, c)) + column, err := selectColSQL(ctx, c) + if err != nil { + return buildFailure("build_insert", err) + } + sb.WriteString(column) } } - return sb.String(), ctx.Args() + return sb.String(), ctx.Args(), nil } // ------------------------------------------------------------------- @@ -271,7 +355,13 @@ func (b *InsertBuilder) Build(d dialect.Dialect) (string, []any) { // buildOnConflict emits PostgreSQL / SQLite style: // // ON CONFLICT (cols) DO NOTHING | DO UPDATE SET … -func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClause) { +func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClause) error { + if u.conflictTargetSet && len(u.conflictCols) == 0 && u.conflictConstraint == "" { + return NewError(CodeBuildValidation, "build_insert", "conflict target is empty") + } + if !u.doNothing && !u.conflictTargetSet { + return NewError(CodeBuildValidation, "build_insert", "upsert update requires a conflict target") + } sb.WriteString(" ON CONFLICT") switch { @@ -281,18 +371,26 @@ func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClaus if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(c)) + column, err := ctx.Quote(c) + if err != nil { + return err + } + sb.WriteString(column) } sb.WriteString(")") case u.conflictConstraint != "": sb.WriteString(" ON CONSTRAINT ") - sb.WriteString(ctx.Quote(u.conflictConstraint)) + constraint, err := ctx.Quote(u.conflictConstraint) + if err != nil { + return err + } + sb.WriteString(constraint) } - if u.doNothing || (len(u.sets) == 0 && len(u.excluded) == 0) { - // Emit DO NOTHING when explicitly requested or when there are no SET - // assignments — an empty DO UPDATE SET list is invalid SQL. + if u.doNothing { sb.WriteString(" DO NOTHING") + } else if len(u.sets) == 0 && len(u.excluded) == 0 { + return NewError(CodeBuildValidation, "build_insert", "upsert update contains no assignments") } else { sb.WriteString(" DO UPDATE SET ") first := true @@ -300,7 +398,11 @@ func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClaus if !first { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(s.col)) + column, err := ctx.Quote(s.col) + if err != nil { + return err + } + sb.WriteString(column) sb.WriteString(" = ") sb.WriteString(ctx.Add(s.val)) first = false @@ -309,12 +411,17 @@ func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClaus if !first { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(col)) + column, err := ctx.Quote(col) + if err != nil { + return err + } + sb.WriteString(column) sb.WriteString(" = EXCLUDED.") - sb.WriteString(ctx.Quote(col)) + sb.WriteString(column) first = false } } + return nil } // buildOnDuplicateKey emits MySQL style: @@ -323,14 +430,15 @@ func buildOnConflict(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClaus // // Note: MySQL ignores the conflict-target columns — the conflict is determined // by the table's PRIMARY KEY and UNIQUE indexes automatically. -func buildOnDuplicateKey(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClause) { - if u.doNothing || (len(u.sets) == 0 && len(u.excluded) == 0) { - // MySQL has no DO NOTHING equivalent in ON DUPLICATE KEY UPDATE syntax. - // Callers should use IgnoreConflicts() to get INSERT IGNORE INTO instead. - // Also omit the clause when there are no SET assignments — an empty - // ON DUPLICATE KEY UPDATE list is invalid SQL. - // Emit nothing so the statement remains valid (just a regular INSERT). - return +func buildOnDuplicateKey(sb *strings.Builder, ctx *expr.BuildContext, u *upsertClause) error { + if u.conflictTargetSet { + return NewError(CodeUnsupportedFeature, "build_insert", "conflict targets are not supported by this dialect") + } + if u.doNothing { + return NewError(CodeUnsupportedFeature, "build_insert", "do nothing upserts are not supported by this dialect") + } + if len(u.sets) == 0 && len(u.excluded) == 0 { + return NewError(CodeBuildValidation, "build_insert", "upsert update contains no assignments") } sb.WriteString(" ON DUPLICATE KEY UPDATE ") first := true @@ -338,7 +446,11 @@ func buildOnDuplicateKey(sb *strings.Builder, ctx *expr.BuildContext, u *upsertC if !first { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(s.col)) + column, err := ctx.Quote(s.col) + if err != nil { + return err + } + sb.WriteString(column) sb.WriteString(" = ") sb.WriteString(ctx.Add(s.val)) first = false @@ -347,12 +459,17 @@ func buildOnDuplicateKey(sb *strings.Builder, ctx *expr.BuildContext, u *upsertC if !first { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(col)) + column, err := ctx.Quote(col) + if err != nil { + return err + } + sb.WriteString(column) sb.WriteString(" = VALUES(") - sb.WriteString(ctx.Quote(col)) + sb.WriteString(column) sb.WriteString(")") first = false } + return nil } // ------------------------------------------------------------------- @@ -365,12 +482,22 @@ func buildOnDuplicateKey(sb *strings.Builder, ctx *expr.BuildContext, u *upsertC // - Pointer fields: skip if nil // - Map/slice fields: skip if nil or len == 0 // - Other fields: always included -func structToColVals(row any) (cols []string, vals []any) { +func structToColVals(row any) (cols []string, vals []any, err error) { + if row == nil { + return nil, nil, fmt.Errorf("insert row is nil") + } rv := reflect.ValueOf(row) if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return nil, nil, fmt.Errorf("insert row is nil") + } rv = rv.Elem() } + if rv.Kind() != reflect.Struct { + return nil, nil, fmt.Errorf("insert row must be a struct") + } rt := rv.Type() + seen := make(map[string]struct{}) for i := 0; i < rt.NumField(); i++ { field := rt.Field(i) @@ -380,10 +507,28 @@ func structToColVals(row any) (cols []string, vals []any) { if tag == "" || tag == "-" { continue } + if field.PkgPath != "" || !fv.CanInterface() { + return nil, nil, fmt.Errorf("insert row contains a tagged unexported field") + } - parts := strings.SplitN(tag, ",", 2) + parts := strings.Split(tag, ",") colName := parts[0] - omitempty := len(parts) > 1 && strings.Contains(parts[1], "omitempty") + if colName == "" { + return nil, nil, fmt.Errorf("insert row contains an empty db tag") + } + if _, ok := seen[colName]; ok { + return nil, nil, fmt.Errorf("insert row contains duplicate db tags") + } + seen[colName] = struct{}{} + omitempty := false + for _, option := range parts[1:] { + switch option { + case "", "omitempty": + omitempty = omitempty || option == "omitempty" + default: + return nil, nil, fmt.Errorf("insert row contains an unsupported db tag option") + } + } if omitempty && isEmptyValue(fv) { continue @@ -404,7 +549,19 @@ func structToColVals(row any) (cols []string, vals []any) { cols = append(cols, colName) vals = append(vals, fv.Interface()) } - return + return cols, vals, nil +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true } // isEmptyValue returns true for values that omitempty should treat as absent: diff --git a/query/insert_internal_test.go b/query/insert_internal_test.go new file mode 100644 index 0000000..8d18891 --- /dev/null +++ b/query/insert_internal_test.go @@ -0,0 +1,57 @@ +package query + +import ( + "errors" + "testing" +) + +func TestValueSlicePreservesFirstBuildError(t *testing.T) { + first := errors.New("first build error") + type namedRow struct { + Name string `db:"name"` + } + var nilRows *[]namedRow + + cases := []struct { + name string + base *InsertBuilder + rows any + }{ + {"nil", &InsertBuilder{buildErr: first}, nil}, + {"typed nil", &InsertBuilder{buildErr: first}, nilRows}, + {"wrong kind", &InsertBuilder{buildErr: first}, 42}, + {"inconsistent columns", &InsertBuilder{colNames: []string{"other"}, buildErr: first}, []namedRow{{Name: "alice"}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.base.ValueSlice(tc.rows) + if got.buildErr != first { + t.Fatalf("buildErr = %v, want original error %v", got.buildErr, first) + } + }) + } +} + +func TestValueSliceCopiesExistingRowsBeforeAppend(t *testing.T) { + type namedRow struct { + Name string `db:"name"` + } + + rows := make([][]any, 1, 3) + rows[0] = []any{"base"} + base := &InsertBuilder{colNames: []string{"name"}, rows: rows} + + left := base.ValueSlice([]namedRow{{Name: "left"}}) + right := base.ValueSlice([]namedRow{{Name: "right"}}) + + if got := left.rows[1][0]; got != "left" { + t.Fatalf("left row = %v, want left; sibling append mutated shared backing array", got) + } + if got := right.rows[1][0]; got != "right" { + t.Fatalf("right row = %v, want right", got) + } + if len(base.rows) != 1 { + t.Fatalf("base row count = %d, want 1", len(base.rows)) + } +} diff --git a/query/query.go b/query/query.go index e7a9ab6..de28cc4 100644 --- a/query/query.go +++ b/query/query.go @@ -7,7 +7,7 @@ // // Typical usage: // -// sql, args := query.Select(UsersT.ID, UsersT.Name). +// sql, args, err := query.Select(UsersT.ID, UsersT.Name). // From(UsersT). // Where(expr.And( // UsersT.RealmID.EQ(realmID), @@ -19,6 +19,9 @@ package query import ( + "errors" + "reflect" + "github.com/sofired/grizzle/dialect" "github.com/sofired/grizzle/expr" ) @@ -57,20 +60,31 @@ type joinClause struct { // Shared build helper // ------------------------------------------------------------------- -func buildWhere(ctx *expr.BuildContext, where expr.Expression) string { +func buildWhere(ctx *expr.BuildContext, where expr.Expression) (string, error) { if where == nil { - return "" + return "", nil + } + if isNilValue(where) { + return "", NewError(CodeBuildValidation, "build_where", "where predicate is nil") + } + sql, err := where.RenderSQL(ctx) + if err != nil { + return "", err } - return " WHERE " + where.ToSQL(ctx) + return " WHERE " + sql, nil } -func buildOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) string { +func buildOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) (string, error) { if len(exprs) == 0 { - return "" + return "", nil } parts := make([]string, len(exprs)) for i, o := range exprs { - parts[i] = o.ToSQL(ctx) + part, err := o.RenderSQL(ctx) + if err != nil { + return "", err + } + parts[i] = part } s := " ORDER BY " for i, p := range parts { @@ -79,10 +93,52 @@ func buildOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) string { } s += p } - return s + return s, nil } // Build is a convenience wrapper to produce SQL + args from a dialect in one call. type Builder interface { - Build(d dialect.Dialect) (string, []any) + Build(d dialect.Dialect) (string, []any, error) +} + +func newBuildContext(d dialect.Dialect) (*expr.BuildContext, error) { + if isNilValue(d) { + return nil, NewError(CodeUnsupportedDialect, "build_query", "dialect is nil") + } + return expr.NewBuildContext(d), nil +} + +func isNilValue(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func normalizeBuildError(op string, err error) error { + if err == nil { + return nil + } + var buildErr *Error + if errors.As(err, &buildErr) { + return buildErr + } + return NewError(CodeBuildValidation, op, "query rendering failed") +} + +func buildFailure(op string, err error) (string, []any, error) { + return "", nil, normalizeBuildError(op, err) +} + +func quoteTableSource(ctx *expr.BuildContext, table TableSource) (string, error) { + if isNilValue(table) { + return "", NewError(CodeBuildValidation, "render_table_source", "table source is nil") + } + return ctx.Quote(table.GrizTableName()) } diff --git a/query/query_test.go b/query/query_test.go index 984f639..73e05a9 100644 --- a/query/query_test.go +++ b/query/query_test.go @@ -1,6 +1,7 @@ package query_test import ( + "errors" "fmt" "strings" "testing" @@ -16,11 +17,14 @@ import ( // assertSQL is a small helper that builds a query and compares the output. func assertSQL(t *testing.T, name string, b interface { - Build(dialect.Dialect) (string, []any) + Build(dialect.Dialect) (string, []any, error) }, wantSQL string, wantArgs []any) { t.Helper() t.Run(name, func(t *testing.T) { - gotSQL, gotArgs := b.Build(dialect.Postgres) + gotSQL, gotArgs, err := b.Build(dialect.Postgres) + if err != nil { + t.Fatalf("Build() error: %v", err) + } if gotSQL != wantSQL { t.Errorf("SQL mismatch\n got: %s\nwant: %s", gotSQL, wantSQL) } @@ -36,6 +40,20 @@ func assertSQL(t *testing.T, name string, b interface { }) } +func assertBuildError(t *testing.T, b query.Builder, d dialect.Dialect, want error) { + t.Helper() + sql, args, err := b.Build(d) + if sql != "" { + t.Errorf("SQL = %q, want empty", sql) + } + if args != nil { + t.Errorf("args = %v, want nil", args) + } + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} + // ------------------------------------------------------------------- // SELECT tests // ------------------------------------------------------------------- @@ -134,7 +152,7 @@ func TestSelect_WhereNilDropped(t *testing.T) { func TestSelect_WhereNilAndReturnsNil(t *testing.T) { // And() with only nils should produce nil, which means no WHERE clause q := query.Select().From(ts.UsersT).Where(expr.And(nil, nil)) - sql, _ := q.Build(dialect.Postgres) + sql, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "users"` if sql != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", sql, want) @@ -231,7 +249,7 @@ func TestSelect_DynamicSearch(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000003") t.Run("all params nil → only base condition", func(t *testing.T) { - sql, args := buildQuery(SearchParams{}).Build(dialect.Postgres) + sql, args, _ := buildQuery(SearchParams{}).Build(dialect.Postgres) want := `SELECT "users"."id", "users"."username", "users"."email" FROM "users" WHERE "users"."deleted_at" IS NULL` if sql != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", sql, want) @@ -243,7 +261,7 @@ func TestSelect_DynamicSearch(t *testing.T) { t.Run("realm + username filter", func(t *testing.T) { name := "alice" - sql, args := buildQuery(SearchParams{RealmID: &realmID, Username: &name}).Build(dialect.Postgres) + sql, args, _ := buildQuery(SearchParams{RealmID: &realmID, Username: &name}).Build(dialect.Postgres) wantSQL := `SELECT "users"."id", "users"."username", "users"."email" FROM "users" WHERE ("users"."deleted_at" IS NULL AND "users"."realm_id" = $1 AND "users"."username" ILIKE $2)` if sql != wantSQL { t.Errorf("SQL mismatch\n got: %s\nwant: %s", sql, wantSQL) @@ -314,7 +332,7 @@ func TestInsert_IgnoreConflicts_MySQL(t *testing.T) { name := "test-realm" row := ts.RealmInsert{Name: name} b := query.InsertInto(ts.RealmsT).Values(row).IgnoreConflicts() - sql, args := b.Build(dialect.MySQL) + sql, args, _ := b.Build(dialect.MySQL) if sql != "INSERT IGNORE INTO `realms` (`name`) VALUES (?)" { t.Errorf("unexpected SQL: %s", sql) } @@ -327,8 +345,8 @@ func TestInsert_IgnoreConflicts_SQLite(t *testing.T) { name := "test-realm" row := ts.RealmInsert{Name: name} b := query.InsertInto(ts.RealmsT).Values(row).IgnoreConflicts() - sql, args := b.Build(dialect.SQLite) - if sql != `INSERT OR IGNORE INTO "realms" ("name") VALUES (?)` { + sql, args, _ := b.Build(dialect.SQLite) + if sql != `INSERT INTO "realms" ("name") VALUES (?) ON CONFLICT DO NOTHING` { t.Errorf("unexpected SQL: %s", sql) } if len(args) != 1 || args[0] != name { @@ -336,15 +354,21 @@ func TestInsert_IgnoreConflicts_SQLite(t *testing.T) { } } -func TestInsert_IgnoreConflicts_Postgres_Noop(t *testing.T) { - // PostgreSQL has no INSERT IGNORE equivalent; flag is silently ignored. +func TestInsert_IgnoreConflicts_Postgres(t *testing.T) { name := "test-realm" row := ts.RealmInsert{Name: name} b := query.InsertInto(ts.RealmsT).Values(row).IgnoreConflicts() - sql, _ := b.Build(dialect.Postgres) - if sql != `INSERT INTO "realms" ("name") VALUES ($1)` { - t.Errorf("unexpected SQL: %s", sql) - } + assertSQL(t, "ignore conflicts postgres", b, + `INSERT INTO "realms" ("name") VALUES ($1) ON CONFLICT DO NOTHING`, + []any{name}, + ) +} + +func TestInsert_IgnoreConflicts_CustomDialectReturnsUnsupportedFeature(t *testing.T) { + b := query.InsertInto(ts.RealmsT). + Values(ts.RealmInsert{Name: "test-realm"}). + IgnoreConflicts() + assertBuildError(t, b, noCTEDialect{}, query.ErrUnsupportedFeature) } func TestUpsert_DoUpdateSetExcluded(t *testing.T) { @@ -448,92 +472,69 @@ func TestUpsert_DoUpdateSetStruct(t *testing.T) { ) } -// TestUpsert_DoUpdateSetStruct_NilInput verifies that passing nil to -// DoUpdateSetStruct falls back to DO NOTHING rather than emitting an invalid -// empty DO UPDATE SET clause. +// TestUpsert_DoUpdateSetStruct_NilInput verifies invalid reflection metadata +// fails closed instead of changing the requested conflict action. func TestUpsert_DoUpdateSetStruct_NilInput(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} - assertSQL(t, "upsert do update set struct nil falls back to do nothing", - query.InsertInto(ts.UsersT). - Values(row). - OnConflict("realm_id", "username"). - DoUpdateSetStruct(nil), - `INSERT INTO "users" ("realm_id", "username") VALUES ($1, $2) ON CONFLICT ("realm_id", "username") DO NOTHING`, - []any{realmID, username}, - ) + b := query.InsertInto(ts.UsersT). + Values(row). + OnConflict("realm_id", "username"). + DoUpdateSetStruct(nil) + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) } -// TestUpsert_DoUpdateSetStruct_NilPointer verifies that passing a nil pointer -// to DoUpdateSetStruct falls back to DO NOTHING. +// TestUpsert_DoUpdateSetStruct_NilPointer verifies nil metadata fails closed. func TestUpsert_DoUpdateSetStruct_NilPointer(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} var upd *ts.UserUpdate // nil pointer - assertSQL(t, "upsert do update set struct nil pointer falls back to do nothing", - query.InsertInto(ts.UsersT). - Values(row). - OnConflict("realm_id", "username"). - DoUpdateSetStruct(upd), - `INSERT INTO "users" ("realm_id", "username") VALUES ($1, $2) ON CONFLICT ("realm_id", "username") DO NOTHING`, - []any{realmID, username}, - ) + b := query.InsertInto(ts.UsersT). + Values(row). + OnConflict("realm_id", "username"). + DoUpdateSetStruct(upd) + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) } -// TestUpsert_DoUpdateSetStruct_NonStruct verifies that passing a non-struct -// to DoUpdateSetStruct falls back to DO NOTHING. +// TestUpsert_DoUpdateSetStruct_NonStruct verifies non-struct metadata fails closed. func TestUpsert_DoUpdateSetStruct_NonStruct(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} - assertSQL(t, "upsert do update set struct non-struct falls back to do nothing", - query.InsertInto(ts.UsersT). - Values(row). - OnConflict("realm_id", "username"). - DoUpdateSetStruct("not-a-struct"), - `INSERT INTO "users" ("realm_id", "username") VALUES ($1, $2) ON CONFLICT ("realm_id", "username") DO NOTHING`, - []any{realmID, username}, - ) + b := query.InsertInto(ts.UsersT). + Values(row). + OnConflict("realm_id", "username"). + DoUpdateSetStruct("not-a-struct") + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) } -// TestUpsert_DoUpdateSetStruct_AllNilFields verifies that passing a struct -// where all pointer fields are nil (producing no SET assignments) falls back -// to DO NOTHING rather than an empty SET list. +// TestUpsert_DoUpdateSetStruct_AllNilFields verifies an empty update action +// is rejected instead of silently becoming DO NOTHING. func TestUpsert_DoUpdateSetStruct_AllNilFields(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} upd := ts.UserUpdate{} // all pointer fields are nil - assertSQL(t, "upsert do update set struct all nil fields falls back to do nothing", - query.InsertInto(ts.UsersT). - Values(row). - OnConflict("realm_id", "username"). - DoUpdateSetStruct(upd), - `INSERT INTO "users" ("realm_id", "username") VALUES ($1, $2) ON CONFLICT ("realm_id", "username") DO NOTHING`, - []any{realmID, username}, - ) + b := query.InsertInto(ts.UsersT). + Values(row). + OnConflict("realm_id", "username"). + DoUpdateSetStruct(upd) + assertBuildError(t, b, dialect.Postgres, query.ErrBuildValidation) } -// TestUpsert_DoUpdateSetStruct_NilInput_MySQL verifies that on MySQL dialects, -// DoUpdateSetStruct with nil input omits the ON DUPLICATE KEY UPDATE clause -// (emitting a plain INSERT) rather than an invalid empty assignment list. +// TestUpsert_DoUpdateSetStruct_NilInput_MySQL verifies the same validation +// contract is used by all dialect renderers. func TestUpsert_DoUpdateSetStruct_NilInput_MySQL(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} - sql, _ := query.InsertInto(ts.UsersT). + b := query.InsertInto(ts.UsersT). Values(row). OnConflict("realm_id", "username"). - DoUpdateSetStruct(nil). - Build(dialect.MySQL) - if strings.Contains(sql, "ON DUPLICATE KEY UPDATE") { - t.Errorf("expected no ON DUPLICATE KEY UPDATE clause, got: %s", sql) - } - if strings.Contains(sql, "UPDATE ") { - t.Errorf("expected no UPDATE clause of any kind, got: %s", sql) - } + DoUpdateSetStruct(nil) + assertBuildError(t, b, dialect.MySQL, query.ErrBuildValidation) } // TestUpsert_DoUpdateSetStruct_AllNilFields_WithPriorSets verifies that when @@ -770,7 +771,7 @@ func TestSelect_MultipleJoinRels(t *testing.T) { From(ts.UsersT). JoinRel(ts.UserRealm). JoinRel(ts.RealmUsers) // contrived but valid structurally - sql, _ := q.Build(dialect.Postgres) + sql, _, _ := q.Build(dialect.Postgres) if !containsN(sql, "LEFT JOIN", 2) { t.Errorf("expected 2 LEFT JOINs in SQL, got: %s", sql) } @@ -802,7 +803,7 @@ func TestMySQL_Placeholder(t *testing.T) { // MySQL uses ? placeholders, not $1 id := uuid.MustParse("00000000-0000-0000-0000-000000000001") q := query.Select().From(ts.UsersT).Where(ts.UsersT.ID.EQ(id)) - sql, args := q.Build(dialect.MySQL) + sql, args, _ := q.Build(dialect.MySQL) if !strings.Contains(sql, "?") { t.Errorf("expected ? placeholder for MySQL, got: %s", sql) } @@ -816,55 +817,44 @@ func TestMySQL_Placeholder(t *testing.T) { func TestMySQL_QuoteIdent(t *testing.T) { q := query.Select().From(ts.UsersT) - sql, _ := q.Build(dialect.MySQL) + sql, _, _ := q.Build(dialect.MySQL) if !strings.Contains(sql, "`users`") { t.Errorf("expected backtick quoting for MySQL, got: %s", sql) } } -func TestMySQL_NoReturning(t *testing.T) { - // RETURNING should be silently dropped for MySQL +func TestMySQL_InsertReturning_ReturnsUnsupportedFeature(t *testing.T) { name := "test-realm" row := ts.RealmInsert{Name: name} - sql, _ := query.InsertInto(ts.RealmsT). + b := query.InsertInto(ts.RealmsT). Values(row). - Returning(ts.RealmsT.ID). - Build(dialect.MySQL) - if strings.Contains(sql, "RETURNING") { - t.Errorf("MySQL INSERT should not have RETURNING clause: %s", sql) - } + Returning(ts.RealmsT.ID) + assertBuildError(t, b, dialect.MySQL, query.ErrUnsupportedFeature) } -func TestMySQL_UpdateNoReturning(t *testing.T) { +func TestMySQL_UpdateReturning_ReturnsUnsupportedFeature(t *testing.T) { id := uuid.MustParse("00000000-0000-0000-0000-000000000001") - sql, _ := query.Update(ts.UsersT). + b := query.Update(ts.UsersT). Set("username", "alice"). Where(ts.UsersT.ID.EQ(id)). - Returning(ts.UsersT.ID). - Build(dialect.MySQL) - if strings.Contains(sql, "RETURNING") { - t.Errorf("MySQL UPDATE should not have RETURNING clause: %s", sql) - } + Returning(ts.UsersT.ID) + assertBuildError(t, b, dialect.MySQL, query.ErrUnsupportedFeature) } -func TestMySQL_DeleteNoReturning(t *testing.T) { +func TestMySQL_DeleteReturning_ReturnsUnsupportedFeature(t *testing.T) { id := uuid.MustParse("00000000-0000-0000-0000-000000000001") - sql, _ := query.DeleteFrom(ts.UsersT). + b := query.DeleteFrom(ts.UsersT). Where(ts.UsersT.ID.EQ(id)). - Returning(ts.UsersT.ID). - Build(dialect.MySQL) - if strings.Contains(sql, "RETURNING") { - t.Errorf("MySQL DELETE should not have RETURNING clause: %s", sql) - } + Returning(ts.UsersT.ID) + assertBuildError(t, b, dialect.MySQL, query.ErrUnsupportedFeature) } func TestMySQL_UpsertDuplicateKey(t *testing.T) { realmID := uuid.MustParse("00000000-0000-0000-0000-000000000001") username := "alice" row := ts.UserInsert{RealmID: realmID, Username: username} - sql, args := query.InsertInto(ts.UsersT). + sql, args, _ := query.InsertInto(ts.UsersT). Values(row). - OnConflict("realm_id", "username"). DoUpdateSetExcluded("email", "enabled"). Build(dialect.MySQL) if !strings.Contains(sql, "ON DUPLICATE KEY UPDATE") { @@ -886,9 +876,8 @@ func TestMySQL_UpsertExplicitSet(t *testing.T) { name := "test-realm" row := ts.RealmInsert{Name: name} enabled := true - sql, _ := query.InsertInto(ts.RealmsT). + sql, _, _ := query.InsertInto(ts.RealmsT). Values(row). - OnConflict("name"). DoUpdateSet("enabled", enabled). Build(dialect.MySQL) if !strings.Contains(sql, "ON DUPLICATE KEY UPDATE") { @@ -1055,7 +1044,7 @@ func TestUniqueStrings(t *testing.T) { func TestJSONB_Arrow(t *testing.T) { ctx := newBuildCtx() - sql := ts.UsersT.Attributes.Arrow("role").ToSQL(ctx) + sql, _ := ts.UsersT.Attributes.Arrow("role").RenderSQL(ctx) want := `"users"."attributes" -> $1` if sql != want { t.Errorf("Arrow SQL: got %q, want %q", sql, want) @@ -1064,7 +1053,7 @@ func TestJSONB_Arrow(t *testing.T) { func TestJSONB_ArrowText(t *testing.T) { ctx := newBuildCtx() - sql := ts.UsersT.Attributes.ArrowText("email").ToSQL(ctx) + sql, _ := ts.UsersT.Attributes.ArrowText("email").RenderSQL(ctx) want := `"users"."attributes" ->> $1` if sql != want { t.Errorf("ArrowText SQL: got %q, want %q", sql, want) @@ -1073,7 +1062,7 @@ func TestJSONB_ArrowText(t *testing.T) { func TestJSONB_Path(t *testing.T) { ctx := newBuildCtx() - sql := ts.UsersT.Attributes.Path("address", "city").ToSQL(ctx) + sql, _ := ts.UsersT.Attributes.Path("address", "city").RenderSQL(ctx) want := `"users"."attributes" #> ARRAY['address', 'city']` if sql != want { t.Errorf("Path SQL: got %q, want %q", sql, want) @@ -1082,7 +1071,7 @@ func TestJSONB_Path(t *testing.T) { func TestJSONB_PathText(t *testing.T) { ctx := newBuildCtx() - sql := ts.UsersT.Attributes.PathText("address", "city").ToSQL(ctx) + sql, _ := ts.UsersT.Attributes.PathText("address", "city").RenderSQL(ctx) want := `"users"."attributes" #>> ARRAY['address', 'city']` if sql != want { t.Errorf("PathText SQL: got %q, want %q", sql, want) @@ -1137,7 +1126,7 @@ func TestJSONB_HasAllKeys_InWhere(t *testing.T) { func TestJSONB_ContainedBy(t *testing.T) { ctx := newBuildCtx() val := map[string]any{"role": "admin", "region": "us"} - sql := ts.UsersT.Attributes.ContainedBy(val).ToSQL(ctx) + sql, _ := ts.UsersT.Attributes.ContainedBy(val).RenderSQL(ctx) // val @> col — the value is on the left if !strings.Contains(sql, "@>") { t.Errorf("ContainedBy SQL missing @>: %s", sql) @@ -1278,7 +1267,7 @@ func TestSubquery_FromSubquery_SharedParams(t *testing.T) { sub := query.FromSubquery(inner, "sub") outerQ := query.Select(ts.UsersT.RealmID).From(sub). Where(ts.UsersT.Username.EQ("alice")) // $2 - gotSQL, gotArgs := outerQ.Build(dialect.Postgres) + gotSQL, gotArgs, _ := outerQ.Build(dialect.Postgres) wantSQL := `SELECT "users"."realm_id" FROM (SELECT "users"."realm_id", COUNT(*) AS "cnt" FROM "users" WHERE "users"."enabled" = $1 GROUP BY "users"."realm_id") AS "sub" WHERE "users"."username" = $2` if gotSQL != wantSQL { t.Errorf("SQL mismatch\n got: %s\nwant: %s", gotSQL, wantSQL) @@ -1365,7 +1354,7 @@ func TestAgg_SumAvgMaxMin(t *testing.T) { {"MIN", expr.Min(ts.UsersT.Username), `MIN("users"."username")`}, } { t.Run(tc.name, func(t *testing.T) { - got := tc.agg.ToSQL(ctx) + got, _ := tc.agg.RenderSQL(ctx) if got != tc.want { t.Errorf("got %s, want %s", got, tc.want) } @@ -1547,7 +1536,7 @@ func TestCase_UsedInWhere(t *testing.T) { Where(expr.Case(). When(ts.UsersT.Enabled.IsTrue(), expr.Lit(1)). Else(expr.Lit(0))) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT "users"."id" FROM "users" WHERE CASE WHEN "users"."enabled" = $1 THEN $2 ELSE $3 END` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -1556,7 +1545,7 @@ func TestCase_UsedInWhere(t *testing.T) { func TestLit_BoundParameter(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := expr.Lit(42).ToSQL(ctx) + got, _ := expr.Lit(42).RenderSQL(ctx) if got != "$1" { t.Errorf("Lit(42) should produce $1, got %s", got) } @@ -1596,7 +1585,7 @@ func TestCTE_MultipleWith(t *testing.T) { With("au", activeUsers). With("ar", activeRealms). From(query.CTERef("au")) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) if !strings.Contains(got, `WITH "au" AS (`) { t.Errorf("missing first CTE in: %s", got) @@ -1620,7 +1609,7 @@ func TestCTE_ParametersSharedAcrossCTE(t *testing.T) { From(query.CTERef("u")). Where(expr.Raw(`"u"."id" IS NOT NULL`)) - got, args := outer.Build(dialect.Postgres) + got, args, _ := outer.Build(dialect.Postgres) want := `WITH "u" AS (SELECT "users"."id" FROM "users" WHERE "users"."username" = $1) SELECT * FROM "u" WHERE "u"."id" IS NOT NULL` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -1638,7 +1627,7 @@ func TestIntColumn_NotIn(t *testing.T) { // Need an IntColumn — add one inline using ColBase directly. col := expr.IntColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "score"}} ctx := expr.NewBuildContext(dialect.Postgres) - got := col.NotIn(1, 2, 3).ToSQL(ctx) + got, _ := col.NotIn(1, 2, 3).RenderSQL(ctx) want := `"users"."score" NOT IN ($1, $2, $3)` if got != want { t.Errorf("got %s, want %s", got, want) @@ -1648,16 +1637,16 @@ func TestIntColumn_NotIn(t *testing.T) { func TestIntColumn_NotIn_Empty(t *testing.T) { col := expr.IntColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "score"}} ctx := expr.NewBuildContext(dialect.Postgres) - got := col.NotIn().ToSQL(ctx) - if got != "TRUE" { - t.Errorf("empty NotIn should produce TRUE, got %s", got) + got, err := col.NotIn().RenderSQL(ctx) + if got != "" || !errors.Is(err, expr.ErrBuildValidation) { + t.Fatalf("got (%q, %v), want empty SQL and ErrBuildValidation", got, err) } } func TestFloatColumn_In(t *testing.T) { col := expr.FloatColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "score"}} ctx := expr.NewBuildContext(dialect.Postgres) - got := col.In(1.5, 2.5).ToSQL(ctx) + got, _ := col.In(1.5, 2.5).RenderSQL(ctx) want := `"users"."score" IN ($1, $2)` if got != want { t.Errorf("got %s, want %s", got, want) @@ -1667,7 +1656,7 @@ func TestFloatColumn_In(t *testing.T) { func TestFloatColumn_NotIn(t *testing.T) { col := expr.FloatColumn{ColBase: expr.ColBase{TableAlias: "users", ColName: "score"}} ctx := expr.NewBuildContext(dialect.Postgres) - got := col.NotIn(1.5, 2.5).ToSQL(ctx) + got, _ := col.NotIn(1.5, 2.5).RenderSQL(ctx) want := `"users"."score" NOT IN ($1, $2)` if got != want { t.Errorf("got %s, want %s", got, want) @@ -1760,7 +1749,7 @@ func TestSelect_ForUpdate(t *testing.T) { func TestSelect_ForShare_Postgres(t *testing.T) { q := query.Select().From(ts.UsersT).ForShare() - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) if !strings.Contains(got, "FOR SHARE") { t.Errorf("expected FOR SHARE in: %s", got) } @@ -1768,62 +1757,46 @@ func TestSelect_ForShare_Postgres(t *testing.T) { func TestSelect_ForShare_MySQL(t *testing.T) { q := query.Select().From(ts.UsersT).ForShare() - got, _ := q.Build(dialect.MySQL) + got, _, _ := q.Build(dialect.MySQL) if !strings.Contains(got, "LOCK IN SHARE MODE") { t.Errorf("expected LOCK IN SHARE MODE in: %s", got) } } -// TestSelect_ForUpdate_SQLite verifies that FOR UPDATE is silently dropped for -// SQLite, which uses file-level locking and does not support row-level locking. -func TestSelect_ForUpdate_SQLite(t *testing.T) { +func TestSelect_ForUpdate_SQLite_ReturnsUnsupportedFeature(t *testing.T) { q := query.Select().From(ts.UsersT).ForUpdate() - got, _ := q.Build(dialect.SQLite) - if strings.Contains(got, "FOR UPDATE") { - t.Errorf("FOR UPDATE should not be emitted for SQLite, got: %s", got) - } + assertBuildError(t, q, dialect.SQLite, query.ErrUnsupportedFeature) } -// TestSelect_ForShare_SQLite verifies that FOR SHARE is silently dropped for -// SQLite, which uses file-level locking and does not support row-level locking. -func TestSelect_ForShare_SQLite(t *testing.T) { +func TestSelect_ForShare_SQLite_ReturnsUnsupportedFeature(t *testing.T) { q := query.Select().From(ts.UsersT).ForShare() - got, _ := q.Build(dialect.SQLite) - if strings.Contains(got, "FOR SHARE") || strings.Contains(got, "LOCK IN") { - t.Errorf("locking clause should not be emitted for SQLite, got: %s", got) - } + assertBuildError(t, q, dialect.SQLite, query.ErrUnsupportedFeature) } func TestSelect_ForNoKeyUpdate_Postgres(t *testing.T) { q := query.Select().From(ts.UsersT).ForNoKeyUpdate() - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) if !strings.Contains(got, "FOR NO KEY UPDATE") { t.Errorf("expected FOR NO KEY UPDATE in: %s", got) } } -func TestSelect_ForNoKeyUpdate_MySQL(t *testing.T) { +func TestSelect_ForNoKeyUpdate_MySQL_ReturnsUnsupportedFeature(t *testing.T) { q := query.Select().From(ts.UsersT).ForNoKeyUpdate() - got, _ := q.Build(dialect.MySQL) - if strings.Contains(got, "NO KEY") { - t.Errorf("FOR NO KEY UPDATE should not be emitted for MySQL, got: %s", got) - } + assertBuildError(t, q, dialect.MySQL, query.ErrUnsupportedFeature) } func TestSelect_ForKeyShare_Postgres(t *testing.T) { q := query.Select().From(ts.UsersT).ForKeyShare() - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) if !strings.Contains(got, "FOR KEY SHARE") { t.Errorf("expected FOR KEY SHARE in: %s", got) } } -func TestSelect_ForKeyShare_MySQL(t *testing.T) { +func TestSelect_ForKeyShare_MySQL_ReturnsUnsupportedFeature(t *testing.T) { q := query.Select().From(ts.UsersT).ForKeyShare() - got, _ := q.Build(dialect.MySQL) - if strings.Contains(got, "KEY SHARE") { - t.Errorf("FOR KEY SHARE should not be emitted for MySQL, got: %s", got) - } + assertBuildError(t, q, dialect.MySQL, query.ErrUnsupportedFeature) } // aliasedTable is a minimal TableSource whose alias differs from its name, @@ -1836,7 +1809,7 @@ func (a aliasedTable) GrizTableAlias() string { return a.alias } func TestSelect_ForUpdate_Of_Alias(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).ForUpdate().Of(tbl) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR UPDATE OF "o"` if got != want { t.Errorf("OF alias\ngot: %s\nwant: %s", got, want) @@ -1855,63 +1828,40 @@ func TestSelect_ForUpdate_Of_NoAlias(t *testing.T) { func TestSelect_ForShare_Of_Postgres(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).ForShare().Of(tbl) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR SHARE OF "o"` if got != want { t.Errorf("FOR SHARE OF\ngot: %s\nwant: %s", got, want) } } -func TestSelect_ForShare_Of_MySQL_Dropped(t *testing.T) { - // MySQL LOCK IN SHARE MODE does not support OF; it must be dropped. +func TestSelect_ForShare_Of_MySQL_ReturnsUnsupportedFeature(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).ForShare().Of(tbl) - got, _ := q.Build(dialect.MySQL) - if strings.Contains(got, " OF ") { - t.Errorf("MySQL LOCK IN SHARE MODE must not emit OF clause, got: %s", got) - } - if !strings.Contains(got, "LOCK IN SHARE MODE") { - t.Errorf("expected LOCK IN SHARE MODE in: %s", got) - } + assertBuildError(t, q, dialect.MySQL, query.ErrUnsupportedFeature) } -func TestSelect_ForUpdate_Of_MySQL_MultiTable(t *testing.T) { - // MySQL 8.0+ FOR UPDATE supports OF with multiple tables. +func TestSelect_ForUpdate_Of_MySQL_ReturnsUnsupportedFeature(t *testing.T) { t1 := aliasedTable{name: "orders", alias: "o"} - t2 := aliasedTable{name: "items", alias: "i"} - q := query.Select().From(t1).ForUpdate().Of(t1, t2) - got, _ := q.Build(dialect.MySQL) - if strings.Count(got, " OF ") != 1 { - t.Fatalf("expected exactly one OF clause in: %s", got) - } - // MySQL uses backtick quoting. - if !strings.Contains(got, "`o`") { - t.Errorf("expected first table alias `o` in OF clause: %s", got) - } - if !strings.Contains(got, "`i`") { - t.Errorf("expected second table alias `i` in OF clause: %s", got) - } + q := query.Select().From(t1).ForUpdate().Of(t1) + assertBuildError(t, q, dialect.MySQL, query.ErrUnsupportedFeature) } func TestSelect_Of_BeforeForUpdate(t *testing.T) { // Of() can precede ForUpdate(); call order does not matter. tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).Of(tbl).ForUpdate() - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR UPDATE OF "o"` if got != want { t.Errorf("Of before ForUpdate\ngot: %s\nwant: %s", got, want) } } -func TestSelect_ForUpdate_Of_SQLite_Dropped(t *testing.T) { - // SQLite has no row-level locking; the entire clause must be dropped. +func TestSelect_ForUpdate_Of_SQLite_ReturnsUnsupportedFeature(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).ForUpdate().Of(tbl) - got, _ := q.Build(dialect.SQLite) - if strings.Contains(got, "FOR UPDATE") || strings.Contains(got, " OF ") { - t.Errorf("SQLite must drop all locking clauses, got: %s", got) - } + assertBuildError(t, q, dialect.SQLite, query.ErrUnsupportedFeature) } // ------------------------------------------------------------------- @@ -1920,7 +1870,7 @@ func TestSelect_ForUpdate_Of_SQLite_Dropped(t *testing.T) { func TestSelect_For_SkipLocked_Postgres(t *testing.T) { q := query.Select().From(ts.UsersT).For(query.LockForUpdate, query.SkipLocked) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "users" FOR UPDATE SKIP LOCKED` if got != want { t.Errorf("got: %s\nwant: %s", got, want) @@ -1929,7 +1879,7 @@ func TestSelect_For_SkipLocked_Postgres(t *testing.T) { func TestSelect_For_NoWait_Postgres(t *testing.T) { q := query.Select().From(ts.UsersT).For(query.LockForUpdate, query.NoWait) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "users" FOR UPDATE NOWAIT` if got != want { t.Errorf("got: %s\nwant: %s", got, want) @@ -1938,7 +1888,7 @@ func TestSelect_For_NoWait_Postgres(t *testing.T) { func TestSelect_For_SkipLocked_MySQL(t *testing.T) { q := query.Select().From(ts.UsersT).For(query.LockForUpdate, query.SkipLocked) - got, _ := q.Build(dialect.MySQL) + got, _, _ := q.Build(dialect.MySQL) // MySQL uses backtick quoting and appends SKIP LOCKED after FOR UPDATE. if !strings.Contains(got, "FOR UPDATE") { t.Errorf("expected FOR UPDATE in: %s", got) @@ -1950,7 +1900,7 @@ func TestSelect_For_SkipLocked_MySQL(t *testing.T) { func TestSelect_For_NoWait_MySQL(t *testing.T) { q := query.Select().From(ts.UsersT).For(query.LockForUpdate, query.NoWait) - got, _ := q.Build(dialect.MySQL) + got, _, _ := q.Build(dialect.MySQL) if !strings.Contains(got, "FOR UPDATE") { t.Errorf("expected FOR UPDATE in: %s", got) } @@ -1959,19 +1909,15 @@ func TestSelect_For_NoWait_MySQL(t *testing.T) { } } -func TestSelect_For_SkipLocked_SQLite_Dropped(t *testing.T) { - // SQLite has no row-level locking; the entire clause including opts must be dropped. +func TestSelect_For_SkipLocked_SQLite_ReturnsUnsupportedFeature(t *testing.T) { q := query.Select().From(ts.UsersT).For(query.LockForUpdate, query.SkipLocked) - got, _ := q.Build(dialect.SQLite) - if strings.Contains(got, "FOR UPDATE") || strings.Contains(got, "SKIP LOCKED") { - t.Errorf("SQLite must drop all locking clauses, got: %s", got) - } + assertBuildError(t, q, dialect.SQLite, query.ErrUnsupportedFeature) } func TestSelect_For_NoKeyUpdate_Of_Postgres(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).For(query.LockForNoKeyUpdate).Of(tbl) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR NO KEY UPDATE OF "o"` if got != want { t.Errorf("got: %s\nwant: %s", got, want) @@ -1981,7 +1927,7 @@ func TestSelect_For_NoKeyUpdate_Of_Postgres(t *testing.T) { func TestSelect_For_KeyShare_Of_Postgres(t *testing.T) { tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).For(query.LockForKeyShare).Of(tbl) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR KEY SHARE OF "o"` if got != want { t.Errorf("got: %s\nwant: %s", got, want) @@ -1992,7 +1938,7 @@ func TestSelect_For_WithOptsAndOf(t *testing.T) { // FOR UPDATE OF "o" NOWAIT — combined Of+opts on postgres. tbl := aliasedTable{name: "orders", alias: "o"} q := query.Select().From(tbl).For(query.LockForUpdate, query.NoWait).Of(tbl) - got, _ := q.Build(dialect.Postgres) + got, _, _ := q.Build(dialect.Postgres) want := `SELECT * FROM "orders" AS "o" FOR UPDATE OF "o" NOWAIT` if got != want { t.Errorf("got: %s\nwant: %s", got, want) @@ -2009,25 +1955,16 @@ func TestSelect_For_WithOptsAndOf(t *testing.T) { func TestUpdate_SetStruct_Nil(t *testing.T) { t.Run("nil interface", func(t *testing.T) { - sql, args := query.Update(ts.UsersT).SetStruct(nil).Build(dialect.Postgres) - if sql != "" || args != nil { - t.Errorf("expected empty result for nil SetStruct, got sql=%q args=%v", sql, args) - } + assertBuildError(t, query.Update(ts.UsersT).SetStruct(nil), dialect.Postgres, query.ErrBuildValidation) }) t.Run("nil pointer exercises !rv.IsValid() guard", func(t *testing.T) { var p *ts.UserUpdate // nil pointer — Elem() returns invalid reflect.Value - sql, args := query.Update(ts.UsersT).SetStruct(p).Build(dialect.Postgres) - if sql != "" || args != nil { - t.Errorf("expected empty result for nil pointer SetStruct, got sql=%q args=%v", sql, args) - } + assertBuildError(t, query.Update(ts.UsersT).SetStruct(p), dialect.Postgres, query.ErrBuildValidation) }) t.Run("non-struct value", func(t *testing.T) { - sql, args := query.Update(ts.UsersT).SetStruct(42).Build(dialect.Postgres) - if sql != "" || args != nil { - t.Errorf("expected empty result for non-struct SetStruct, got sql=%q args=%v", sql, args) - } + assertBuildError(t, query.Update(ts.UsersT).SetStruct(42), dialect.Postgres, query.ErrBuildValidation) }) } @@ -2040,7 +1977,7 @@ func TestUpdate_Limit_MySQL(t *testing.T) { Set("enabled", false). Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(100) - got, args := q.Build(dialect.MySQL) + got, args, _ := q.Build(dialect.MySQL) want := "UPDATE `users` SET `enabled` = ? WHERE `users`.`deleted_at` IS NOT NULL LIMIT 100" if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2050,22 +1987,19 @@ func TestUpdate_Limit_MySQL(t *testing.T) { } } -func TestUpdate_Limit_Postgres_Ignored(t *testing.T) { +func TestUpdate_Limit_Postgres_ReturnsUnsupportedFeature(t *testing.T) { q := query.Update(ts.UsersT). Set("enabled", false). Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(100) - got, _ := q.Build(dialect.Postgres) - if strings.Contains(got, "LIMIT") { - t.Errorf("LIMIT should not appear in Postgres UPDATE: %s", got) - } + assertBuildError(t, q, dialect.Postgres, query.ErrUnsupportedFeature) } func TestDelete_Limit_MySQL(t *testing.T) { q := query.DeleteFrom(ts.UsersT). Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(50) - got, args := q.Build(dialect.MySQL) + got, args, _ := q.Build(dialect.MySQL) want := "DELETE FROM `users` WHERE `users`.`deleted_at` IS NOT NULL LIMIT 50" if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2075,12 +2009,9 @@ func TestDelete_Limit_MySQL(t *testing.T) { } } -func TestDelete_Limit_Postgres_Ignored(t *testing.T) { +func TestDelete_Limit_Postgres_ReturnsUnsupportedFeature(t *testing.T) { q := query.DeleteFrom(ts.UsersT).Where(ts.UsersT.DeletedAt.IsNotNull()).Limit(50) - got, _ := q.Build(dialect.Postgres) - if strings.Contains(got, "LIMIT") { - t.Errorf("LIMIT should not appear in Postgres DELETE: %s", got) - } + assertBuildError(t, q, dialect.Postgres, query.ErrUnsupportedFeature) } func TestUpdate_Limit_SQLite(t *testing.T) { @@ -2088,7 +2019,7 @@ func TestUpdate_Limit_SQLite(t *testing.T) { Set("enabled", false). Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(100) - got, args := q.Build(dialect.SQLite) + got, args, _ := q.Build(dialect.SQLite) want := `UPDATE "users" SET "enabled" = ? WHERE "users"."deleted_at" IS NOT NULL LIMIT 100` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2102,7 +2033,7 @@ func TestDelete_Limit_SQLite(t *testing.T) { q := query.DeleteFrom(ts.UsersT). Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(50) - got, args := q.Build(dialect.SQLite) + got, args, _ := q.Build(dialect.SQLite) want := `DELETE FROM "users" WHERE "users"."deleted_at" IS NOT NULL LIMIT 50` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2118,7 +2049,7 @@ func TestUpdate_Limit_SQLite_WithReturning(t *testing.T) { Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(10). Returning(ts.UsersT.ID) - got, args := q.Build(dialect.SQLite) + got, args, _ := q.Build(dialect.SQLite) want := `UPDATE "users" SET "enabled" = ? WHERE "users"."deleted_at" IS NOT NULL LIMIT 10 RETURNING "users"."id"` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2133,7 +2064,7 @@ func TestDelete_Limit_SQLite_WithReturning(t *testing.T) { Where(ts.UsersT.DeletedAt.IsNotNull()). Limit(10). Returning(ts.UsersT.ID) - got, args := q.Build(dialect.SQLite) + got, args, _ := q.Build(dialect.SQLite) want := `DELETE FROM "users" WHERE "users"."deleted_at" IS NOT NULL LIMIT 10 RETURNING "users"."id"` if got != want { t.Errorf("SQL mismatch\n got: %s\nwant: %s", got, want) @@ -2146,7 +2077,7 @@ func TestDelete_Limit_SQLite_WithReturning(t *testing.T) { func TestUpdate_Limit_Zero_NoClause(t *testing.T) { for _, d := range []dialect.Dialect{dialect.MySQL, dialect.SQLite, dialect.Postgres} { q := query.Update(ts.UsersT).Set("enabled", false).Limit(0) - got, _ := q.Build(d) + got, _, _ := q.Build(d) if strings.Contains(got, "LIMIT") { t.Errorf("dialect %s: LIMIT should not appear when Limit(0): %s", d.Name(), got) } @@ -2156,27 +2087,21 @@ func TestUpdate_Limit_Zero_NoClause(t *testing.T) { func TestDelete_Limit_Zero_NoClause(t *testing.T) { for _, d := range []dialect.Dialect{dialect.MySQL, dialect.SQLite, dialect.Postgres} { q := query.DeleteFrom(ts.UsersT).Limit(0) - got, _ := q.Build(d) + got, _, _ := q.Build(d) if strings.Contains(got, "LIMIT") { t.Errorf("dialect %s: LIMIT should not appear when Limit(0): %s", d.Name(), got) } } } -func TestUpdate_Limit_Negative_NoClause(t *testing.T) { +func TestUpdate_Limit_Negative_ReturnsBuildValidation(t *testing.T) { q := query.Update(ts.UsersT).Set("enabled", false).Limit(-1) - got, _ := q.Build(dialect.MySQL) - if strings.Contains(got, "LIMIT") { - t.Errorf("LIMIT should not appear when Limit(-1): %s", got) - } + assertBuildError(t, q, dialect.MySQL, query.ErrBuildValidation) } -func TestDelete_Limit_Negative_NoClause(t *testing.T) { +func TestDelete_Limit_Negative_ReturnsBuildValidation(t *testing.T) { q := query.DeleteFrom(ts.UsersT).Limit(-1) - got, _ := q.Build(dialect.MySQL) - if strings.Contains(got, "LIMIT") { - t.Errorf("LIMIT should not appear when Limit(-1): %s", got) - } + assertBuildError(t, q, dialect.MySQL, query.ErrBuildValidation) } // ------------------------------------------------------------------- @@ -2185,7 +2110,7 @@ func TestDelete_Limit_Negative_NoClause(t *testing.T) { func TestTimestampColumn_EQCol(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := ts.UsersT.CreatedAt.EQCol(ts.UsersT.UpdatedAt).ToSQL(ctx) + got, _ := ts.UsersT.CreatedAt.EQCol(ts.UsersT.UpdatedAt).RenderSQL(ctx) want := `"users"."created_at" = "users"."updated_at"` if got != want { t.Errorf("got %s, want %s", got, want) @@ -2194,7 +2119,7 @@ func TestTimestampColumn_EQCol(t *testing.T) { func TestTimestampColumn_LTCol(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := ts.UsersT.CreatedAt.LTCol(ts.UsersT.DeletedAt).ToSQL(ctx) + got, _ := ts.UsersT.CreatedAt.LTCol(ts.UsersT.DeletedAt).RenderSQL(ctx) want := `"users"."created_at" < "users"."deleted_at"` if got != want { t.Errorf("got %s, want %s", got, want) @@ -2205,7 +2130,7 @@ func TestFloatColumn_GTCol(t *testing.T) { a := expr.FloatColumn{ColBase: expr.ColBase{TableAlias: "products", ColName: "price"}} b := expr.FloatColumn{ColBase: expr.ColBase{TableAlias: "products", ColName: "cost"}} ctx := expr.NewBuildContext(dialect.Postgres) - got := a.GTCol(b).ToSQL(ctx) + got, _ := a.GTCol(b).RenderSQL(ctx) want := `"products"."price" > "products"."cost"` if got != want { t.Errorf("got %s, want %s", got, want) @@ -2303,7 +2228,7 @@ func TestSetOp_EmptyOrderBy(t *testing.T) { // No OrderBy call — result must not contain ORDER BY. a := query.Select(ts.UsersT.Username).From(ts.UsersT) b := query.Select(ts.RealmsT.Name).From(ts.RealmsT) - sql, _ := a.Union(b).Build(dialect.Postgres) + sql, _, _ := a.Union(b).Build(dialect.Postgres) if strings.Contains(sql, "ORDER BY") { t.Errorf("expected no ORDER BY in result without OrderBy() call, got: %s", sql) } @@ -2322,7 +2247,7 @@ func TestSetOp_LimitOffset(t *testing.T) { func TestSetOp_SharedParameters(t *testing.T) { a := query.Select(ts.UsersT.Username).From(ts.UsersT).Where(ts.UsersT.Enabled.EQ(true)) b := query.Select(ts.RealmsT.Name).From(ts.RealmsT).Where(ts.RealmsT.Enabled.EQ(false)) - sql, args := a.UnionAll(b).Build(dialect.Postgres) + sql, args, _ := a.UnionAll(b).Build(dialect.Postgres) if !strings.Contains(sql, "$1") || !strings.Contains(sql, "$2") { t.Errorf("expected shared parameter numbering, got: %s", sql) } @@ -2344,7 +2269,7 @@ var ( func TestArith_IntColumn_Add(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Add(10).ToSQL(ctx) + got, _ := scoreCol.Add(10).RenderSQL(ctx) want := `("products"."score" + $1)` if got != want { t.Errorf("got %s, want %s", got, want) @@ -2356,7 +2281,7 @@ func TestArith_IntColumn_Add(t *testing.T) { func TestArith_IntColumn_Sub(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Sub(5).ToSQL(ctx) + got, _ := scoreCol.Sub(5).RenderSQL(ctx) if got != `("products"."score" - $1)` { t.Errorf("unexpected: %s", got) } @@ -2364,7 +2289,7 @@ func TestArith_IntColumn_Sub(t *testing.T) { func TestArith_IntColumn_Mul(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Mul(3).ToSQL(ctx) + got, _ := scoreCol.Mul(3).RenderSQL(ctx) if got != `("products"."score" * $1)` { t.Errorf("unexpected: %s", got) } @@ -2372,7 +2297,7 @@ func TestArith_IntColumn_Mul(t *testing.T) { func TestArith_IntColumn_Div(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Div(2).ToSQL(ctx) + got, _ := scoreCol.Div(2).RenderSQL(ctx) if got != `("products"."score" / $1)` { t.Errorf("unexpected: %s", got) } @@ -2380,7 +2305,7 @@ func TestArith_IntColumn_Div(t *testing.T) { func TestArith_IntColumn_AddCol(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := quantCol.AddCol(scoreCol).ToSQL(ctx) + got, _ := quantCol.AddCol(scoreCol).RenderSQL(ctx) if got != `("orders"."quantity" + "products"."score")` { t.Errorf("unexpected: %s", got) } @@ -2388,7 +2313,7 @@ func TestArith_IntColumn_AddCol(t *testing.T) { func TestArith_FloatColumn_Mul(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := priceCol.Mul(1.1).ToSQL(ctx) + got, _ := priceCol.Mul(1.1).RenderSQL(ctx) if got != `("orders"."price" * $1)` { t.Errorf("unexpected: %s", got) } @@ -2396,7 +2321,7 @@ func TestArith_FloatColumn_Mul(t *testing.T) { func TestArith_FloatColumn_MulCol(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := priceCol.MulCol(discountCol).ToSQL(ctx) + got, _ := priceCol.MulCol(discountCol).RenderSQL(ctx) if got != `("orders"."price" * "orders"."discount")` { t.Errorf("unexpected: %s", got) } @@ -2405,7 +2330,7 @@ func TestArith_FloatColumn_MulCol(t *testing.T) { func TestArith_Chain(t *testing.T) { // (score + 5) * 2 ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Add(5).Mul(2).ToSQL(ctx) + got, _ := scoreCol.Add(5).Mul(2).RenderSQL(ctx) if got != `(("products"."score" + $1) * $2)` { t.Errorf("unexpected: %s", got) } @@ -2413,7 +2338,7 @@ func TestArith_Chain(t *testing.T) { func TestArith_As_SelectAlias(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := priceCol.Mul(0.9).As("discounted").ToSQL(ctx) + got, _ := priceCol.Mul(0.9).As("discounted").RenderSQL(ctx) if got != `("orders"."price" * $1) AS "discounted"` { t.Errorf("unexpected: %s", got) } @@ -2421,7 +2346,7 @@ func TestArith_As_SelectAlias(t *testing.T) { func TestArith_GTE_InWhere(t *testing.T) { ctx := expr.NewBuildContext(dialect.Postgres) - got := scoreCol.Add(10).GTE(100).ToSQL(ctx) + got, _ := scoreCol.Add(10).GTE(100).RenderSQL(ctx) // ("products"."score" + $1) >= $2 if !strings.Contains(got, ">=") { t.Errorf("expected >= operator, got: %s", got) @@ -2456,7 +2381,7 @@ func TestWithRecursive_Basic(t *testing.T) { From(ts.UsersT). InnerJoin(query.CTERef("tree"), idCol.EQCol(treeIDCol)) - sql, args := query.Select(). + sql, args, _ := query.Select(). WithRecursive("tree", anchor, recursive). From(query.CTERef("tree")). Build(dialect.Postgres) @@ -2475,7 +2400,7 @@ func TestWithRecursive_Basic(t *testing.T) { func TestWith_NonRecursive_UsesWITH(t *testing.T) { sub := query.Select(ts.UsersT.ID).From(ts.UsersT).Where(ts.UsersT.Enabled.IsTrue()) - sql, _ := query.Select(). + sql, _, _ := query.Select(). With("active", sub). From(query.CTERef("active")). Build(dialect.Postgres) @@ -2490,7 +2415,7 @@ func TestWithRecursive_AndRegularCTE(t *testing.T) { anchor := query.Select(ts.UsersT.ID).From(ts.UsersT).Where(ts.UsersT.Enabled.IsTrue()) recursive := query.Select(ts.UsersT.ID).From(ts.UsersT) - sql, _ := query.Select(). + sql, _, _ := query.Select(). With("regular", sub). WithRecursive("tree", anchor, recursive). From(query.CTERef("tree")). @@ -2566,7 +2491,7 @@ func TestTableAlias_As_ReturnsAliasedCopy(t *testing.T) { func TestTableAlias_ColumnRefsUseAlias(t *testing.T) { mgr := ts.EmployeesT.As("manager") ctx := expr.NewBuildContext(dialect.Postgres) - got := mgr.Name.EQ("Alice").ToSQL(ctx) + got, _ := mgr.Name.EQ("Alice").RenderSQL(ctx) want := `"manager"."name" = $1` if got != want { t.Errorf("column ref with alias: got %q, want %q", got, want) @@ -2580,7 +2505,7 @@ func TestTableAlias_OriginalUnchanged(t *testing.T) { t.Errorf("As() mutated original: GrizTableAlias = %q", ts.EmployeesT.GrizTableAlias()) } ctx := expr.NewBuildContext(dialect.Postgres) - got := ts.EmployeesT.Name.EQ("Alice").ToSQL(ctx) + got, _ := ts.EmployeesT.Name.EQ("Alice").RenderSQL(ctx) want := `"employees"."name" = $1` if got != want { t.Errorf("original column ref changed: got %q, want %q", got, want) @@ -2651,7 +2576,7 @@ func TestTableAlias_ChainedAs(t *testing.T) { t.Errorf("chained As mutated intermediate: GrizTableAlias got %q, want %q", a.GrizTableAlias(), "first") } ctx := expr.NewBuildContext(dialect.Postgres) - got := b.Name.EQ("Bob").ToSQL(ctx) + got, _ := b.Name.EQ("Bob").RenderSQL(ctx) want := `"second"."name" = $1` if got != want { t.Errorf("chained As column ref: got %q, want %q", got, want) @@ -2680,7 +2605,7 @@ func TestTableAlias_EmptyStringIsNoOp(t *testing.T) { t.Errorf("As(\"\") changed column TableAlias: got %q, want %q", result.Name.TableName(), "employees") } ctx := expr.NewBuildContext(dialect.Postgres) - got := result.Name.EQ("Alice").ToSQL(ctx) + got, _ := result.Name.EQ("Alice").RenderSQL(ctx) want := `"employees"."name" = $1` if got != want { t.Errorf("As(\"\") column ref: got %q, want %q", got, want) @@ -2713,6 +2638,7 @@ func (noCTEDialect) UpsertStyle() dialect.UpsertStyle { return dialect.UpsertOnConflict } func (noCTEDialect) InsertIgnoreClause() string { return "" } +func (noCTEDialect) SupportsIgnoreConflicts() bool { return false } func (noCTEDialect) SupportsCTE() bool { return false } func (noCTEDialect) SupportsWindowFunctions() bool { return true } func (noCTEDialect) SupportsDistinctOn() bool { return false } @@ -2720,6 +2646,7 @@ func (noCTEDialect) SupportsForUpdate() bool { return false } func (noCTEDialect) SupportsForNoKeyUpdate() bool { return false } func (noCTEDialect) SupportsForShareOf() bool { return false } func (noCTEDialect) SupportsFullJoin() bool { return false } +func (noCTEDialect) SupportsRightJoin() bool { return false } func (noCTEDialect) ForShareClause() string { return "" } func (noCTEDialect) SupportsRegexpMatch() bool { return false } func (noCTEDialect) SupportsFullTextSearch() bool { return false } @@ -2731,229 +2658,94 @@ type noWindowDialect struct{ noCTEDialect } func (noWindowDialect) SupportsCTE() bool { return true } func (noWindowDialect) SupportsWindowFunctions() bool { return false } -func TestCTE_DroppedWhenNotSupported(t *testing.T) { - sub := query.Select(ts.UsersT.ID).From(ts.UsersT) - sql, _ := query.Select(ts.UsersT.ID). - With("recent", sub). - From(query.CTERef("recent")). - Build(noCTEDialect{}) - - if strings.Contains(sql, "WITH") { - t.Errorf("expected WITH clause to be dropped, got: %s", sql) - } - // The CTERef FROM reference is preserved as a plain table name; at runtime - // the database will raise an unknown-table error — the intended fail-loud - // behaviour rather than silently returning wrong rows. - want := `SELECT "users"."id" FROM "recent"` - if sql != want { - t.Errorf("CTE dropped: want %q, got %q", want, sql) - } -} - -func TestCTE_RecursiveDroppedWhenNotSupported(t *testing.T) { +func TestUnsupportedSelectFeatures_ReturnUnsupportedFeature(t *testing.T) { + cte := query.Select(ts.UsersT.ID).From(ts.UsersT) anchor := query.Select(ts.UsersT.ID).From(ts.UsersT).Where(ts.UsersT.Enabled.IsTrue()) recursive := query.Select(ts.UsersT.ID).From(ts.UsersT) - sql, _ := query.Select(ts.UsersT.ID). - WithRecursive("tree", anchor, recursive). - From(query.CTERef("tree")). - Build(noCTEDialect{}) - - // The CTERef FROM reference is preserved as a plain table name; at runtime - // the database will raise an unknown-table error — the intended fail-loud - // behaviour rather than silently returning wrong rows. - want := `SELECT "users"."id" FROM "tree"` - if sql != want { - t.Errorf("recursive CTE dropped: want %q, got %q", want, sql) - } -} -func TestCTE_EmittedWhenSupported(t *testing.T) { - sub := query.Select(ts.UsersT.ID).From(ts.UsersT) - sql, _ := query.Select(ts.UsersT.ID). - With("recent", sub). - From(query.CTERef("recent")). - Build(dialect.Postgres) - - if !strings.HasPrefix(sql, `WITH "recent" AS (`) { - t.Errorf("expected WITH clause, got: %s", sql) - } -} - -func TestDistinctOn_PostgresRendered(t *testing.T) { - sql, _ := query.Select(ts.UsersT.RealmID, ts.UsersT.Username). - From(ts.UsersT). - DistinctOn(ts.UsersT.RealmID). - OrderBy(ts.UsersT.RealmID.Asc(), ts.UsersT.CreatedAt.Desc()). - Build(dialect.Postgres) - - want := `SELECT DISTINCT ON ("users"."realm_id") "users"."realm_id", "users"."username" FROM "users" ORDER BY "users"."realm_id" ASC, "users"."created_at" DESC` - if sql != want { - t.Errorf("SQL mismatch\n got: %s\nwant: %s", sql, want) - } -} - -func TestDistinctOn_DegradesToDistinctOnUnsupportedDialect(t *testing.T) { - // MySQL does not support DISTINCT ON — should degrade to SELECT DISTINCT. - sql, _ := query.Select(ts.UsersT.RealmID, ts.UsersT.Username). - From(ts.UsersT). - DistinctOn(ts.UsersT.RealmID). - Build(dialect.MySQL) - - if strings.Contains(sql, "DISTINCT ON") { - t.Errorf("DISTINCT ON should be dropped for MySQL, got: %s", sql) - } - if !strings.Contains(sql, "SELECT DISTINCT") { - t.Errorf("expected SELECT DISTINCT fallback, got: %s", sql) - } -} - -func TestDistinctOn_DegradesToDistinctForSQLite(t *testing.T) { - // SQLite does not support DISTINCT ON — should degrade to SELECT DISTINCT. - sql, _ := query.Select(ts.UsersT.RealmID, ts.UsersT.Username). - From(ts.UsersT). - DistinctOn(ts.UsersT.RealmID). - Build(dialect.SQLite) - - if strings.Contains(sql, "DISTINCT ON") { - t.Errorf("DISTINCT ON should be dropped for SQLite, got: %s", sql) - } - if !strings.Contains(sql, "SELECT DISTINCT") { - t.Errorf("expected SELECT DISTINCT fallback, got: %s", sql) - } -} - -func TestDistinctOn_MultipleCols(t *testing.T) { - sql, _ := query.Select(ts.UsersT.RealmID, ts.UsersT.Username, ts.UsersT.ID). - From(ts.UsersT). - DistinctOn(ts.UsersT.RealmID, ts.UsersT.Username). - Build(dialect.Postgres) - - if !strings.Contains(sql, `DISTINCT ON ("users"."realm_id", "users"."username")`) { - t.Errorf("expected DISTINCT ON with two cols, got: %s", sql) - } -} - -func TestWindowFunctions_DroppedWhenNotSupported(t *testing.T) { - // Window functions should be silently removed from the SELECT list. - sql, _ := query.Select( - ts.UsersT.ID, - expr.RowNumber().PartitionBy(ts.UsersT.RealmID).As("rn"), - ).From(ts.UsersT).Build(noWindowDialect{}) - - if strings.Contains(sql, "ROW_NUMBER") { - t.Errorf("expected ROW_NUMBER to be dropped, got: %s", sql) - } - if !strings.Contains(sql, `"users"."id"`) { - t.Errorf("expected non-window column to remain, got: %s", sql) - } -} - -func TestWindowFunctions_AllDroppedFallsBackToStar(t *testing.T) { - // When all selected columns are window functions and dialect drops them, fall back to *. - sql, _ := query.Select( - expr.RowNumber().As("rn"), - expr.Rank().As("rnk"), - ).From(ts.UsersT).Build(noWindowDialect{}) - - if !strings.Contains(sql, "SELECT *") { - t.Errorf("expected SELECT * fallback when all window cols dropped, got: %s", sql) - } -} - -func TestWindowFunctions_EmittedWhenSupported(t *testing.T) { - sql, _ := query.Select( - ts.UsersT.ID, - expr.RowNumber().PartitionBy(ts.UsersT.RealmID).As("rn"), - ).From(ts.UsersT).Build(dialect.Postgres) - - if !strings.Contains(sql, "ROW_NUMBER()") { - t.Errorf("expected ROW_NUMBER in output, got: %s", sql) - } -} - -func TestFullJoin_DroppedOnMySQL(t *testing.T) { - sql, _ := query.Select(ts.UsersT.ID, ts.RealmsT.ID). - From(ts.UsersT). - FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). - Build(dialect.MySQL) - - if strings.Contains(sql, "FULL JOIN") { - t.Errorf("FULL JOIN should be dropped for MySQL, got: %s", sql) - } -} - -func TestFullJoin_DroppedOnSQLite(t *testing.T) { - sql, _ := query.Select(ts.UsersT.ID). - From(ts.UsersT). - FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). - Build(dialect.SQLite) - - if strings.Contains(sql, "FULL JOIN") { - t.Errorf("FULL JOIN should be dropped for SQLite, got: %s", sql) - } -} - -func TestFullJoin_EmittedOnPostgres(t *testing.T) { - sql, _ := query.Select(ts.UsersT.ID, ts.RealmsT.ID). - From(ts.UsersT). - FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). - Build(dialect.Postgres) - - if !strings.Contains(sql, "FULL JOIN") { - t.Errorf("expected FULL JOIN in PostgreSQL output, got: %s", sql) - } -} - -func TestFullJoin_MixedJoins_OnlyFullDropped(t *testing.T) { - // Inner join should remain; full join should be dropped on MySQL. - sql, _ := query.Select(ts.UsersT.ID). - From(ts.UsersT). - InnerJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). - FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). - Build(dialect.MySQL) - - if strings.Contains(sql, "FULL JOIN") { - t.Errorf("FULL JOIN should be dropped for MySQL, got: %s", sql) - } - if !strings.Contains(sql, "INNER JOIN") { - t.Errorf("INNER JOIN should remain, got: %s", sql) + cases := []struct { + name string + b query.Builder + d dialect.Dialect + }{ + {"cte", query.Select(ts.UsersT.ID).With("recent", cte).From(query.CTERef("recent")), noCTEDialect{}}, + {"recursive cte", query.Select(ts.UsersT.ID).WithRecursive("tree", anchor, recursive).From(query.CTERef("tree")), noCTEDialect{}}, + {"distinct on mysql", query.Select(ts.UsersT.RealmID).From(ts.UsersT).DistinctOn(ts.UsersT.RealmID), dialect.MySQL}, + {"distinct on sqlite", query.Select(ts.UsersT.RealmID).From(ts.UsersT).DistinctOn(ts.UsersT.RealmID), dialect.SQLite}, + {"window function", query.Select(ts.UsersT.ID, expr.RowNumber().As("rn")).From(ts.UsersT), noWindowDialect{}}, + {"aliased window function", query.Select(ts.UsersT.ID, expr.ColAs(expr.RowNumber(), "rn")).From(ts.UsersT), noWindowDialect{}}, + {"window function in order by", query.Select(ts.UsersT.ID).From(ts.UsersT).OrderBy(expr.RowNumber().Asc()), noWindowDialect{}}, + {"right join sqlite", query.Select(ts.UsersT.ID).From(ts.UsersT).RightJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)), dialect.SQLite}, + {"full join mysql", query.Select(ts.UsersT.ID).From(ts.UsersT).FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)), dialect.MySQL}, + {"full join sqlite", query.Select(ts.UsersT.ID).From(ts.UsersT).FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)), dialect.SQLite}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assertBuildError(t, tc.b, tc.d, query.ErrUnsupportedFeature) + }) } } -func TestWindowFunctions_AliasedColWrappingWindowExpr_DroppedWhenNotSupported(t *testing.T) { - // expr.ColAs(windowExpr, alias) wraps a WindowExpr in an AliasedCol. - // The window-function gate must unwrap AliasedCol to detect the inner WindowExpr - // and drop it on dialects that do not support window functions. - sql, _ := query.Select( - ts.UsersT.ID, - expr.ColAs(expr.RowNumber(), "rn"), // AliasedCol wrapping a WindowExpr - ).From(ts.UsersT).Build(noWindowDialect{}) +func TestSupportedSelectFeatures_Render(t *testing.T) { + t.Run("cte", func(t *testing.T) { + sub := query.Select(ts.UsersT.ID).From(ts.UsersT) + sql, _, err := query.Select(ts.UsersT.ID). + With("recent", sub). + From(query.CTERef("recent")). + Build(dialect.Postgres) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(sql, `WITH "recent" AS (`) { + t.Errorf("expected WITH clause, got: %s", sql) + } + }) - if strings.Contains(sql, "ROW_NUMBER") { - t.Errorf("ColAs-wrapped WindowExpr should be dropped on no-window dialect, got: %s", sql) - } - if !strings.Contains(sql, `"users"."id"`) { - t.Errorf("non-window column should remain after dropping ColAs-wrapped WindowExpr, got: %s", sql) - } -} + t.Run("distinct on", func(t *testing.T) { + sql, _, err := query.Select(ts.UsersT.RealmID, ts.UsersT.Username). + From(ts.UsersT). + DistinctOn(ts.UsersT.RealmID). + OrderBy(ts.UsersT.RealmID.Asc(), ts.UsersT.CreatedAt.Desc()). + Build(dialect.Postgres) + if err != nil { + t.Fatal(err) + } + want := `SELECT DISTINCT ON ("users"."realm_id") "users"."realm_id", "users"."username" FROM "users" ORDER BY "users"."realm_id" ASC, "users"."created_at" DESC` + if sql != want { + t.Errorf("SQL mismatch\n got: %s\nwant: %s", sql, want) + } + }) -func TestWindowFunctions_AliasedColWrappingWindowExpr_AllDroppedFallsBackToStar(t *testing.T) { - // When all columns are ColAs-wrapped WindowExprs and the dialect drops them, - // the query should fall back to SELECT * (same as bare WindowExpr). - sql, _ := query.Select( - expr.ColAs(expr.RowNumber(), "rn"), - expr.ColAs(expr.Rank(), "rnk"), - ).From(ts.UsersT).Build(noWindowDialect{}) + t.Run("window function", func(t *testing.T) { + sql, _, err := query.Select(ts.UsersT.ID, expr.RowNumber().PartitionBy(ts.UsersT.RealmID).As("rn")). + From(ts.UsersT). + Build(dialect.Postgres) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(sql, "ROW_NUMBER()") { + t.Errorf("expected ROW_NUMBER in output, got: %s", sql) + } + }) - if !strings.Contains(sql, "SELECT *") { - t.Errorf("expected SELECT * fallback when all ColAs-wrapped window cols dropped, got: %s", sql) - } + t.Run("full join", func(t *testing.T) { + sql, _, err := query.Select(ts.UsersT.ID, ts.RealmsT.ID). + From(ts.UsersT). + FullJoin(ts.RealmsT, ts.UsersT.RealmID.EQCol(ts.RealmsT.ID)). + Build(dialect.Postgres) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(sql, "FULL JOIN") { + t.Errorf("expected FULL JOIN in output, got: %s", sql) + } + }) } func TestDistinctOn_EmptyColsIsNoOp(t *testing.T) { // DistinctOn() with no arguments sets distinct=true but distinctOn stays empty. // On non-supporting dialects this should degrade to SELECT DISTINCT (not panic). - sql, _ := query.Select(ts.UsersT.ID). + sql, _, _ := query.Select(ts.UsersT.ID). From(ts.UsersT). DistinctOn(). // empty variadic Build(dialect.MySQL) diff --git a/query/select.go b/query/select.go index 6574965..031d8c4 100644 --- a/query/select.go +++ b/query/select.go @@ -23,11 +23,11 @@ type LockOption string const ( // NoWait causes the query to fail immediately if any selected row cannot - // be locked. Supported by PostgreSQL and MySQL 8.0+; silently dropped for SQLite. + // be locked. Unsupported dialects cause Build to return ErrUnsupportedFeature. NoWait LockOption = "NOWAIT" // SkipLocked causes the query to skip rows that are already locked, returning - // only the rows that could be locked. Supported by PostgreSQL and MySQL 8.0+; - // silently dropped for SQLite. + // only the rows that could be locked. Unsupported dialects cause Build to + // return ErrUnsupportedFeature. SkipLocked LockOption = "SKIP LOCKED" ) @@ -50,6 +50,7 @@ type SelectBuilder struct { lockStrength LockStrength // row-level lock mode (empty = no lock) lockOpts []LockOption // NOWAIT / SKIP LOCKED modifiers lockOf []TableSource // OF table list for row-level locking (PostgreSQL/MySQL) + lockOfSet bool } // cteClause holds a single WITH name AS (...) entry. @@ -88,11 +89,9 @@ func (b *SelectBuilder) Distinct() *SelectBuilder { // // Dialect behaviour: // - PostgreSQL: all four modes are emitted. -// - MySQL: only LockForUpdate (FOR UPDATE) and LockForShare (LOCK IN SHARE MODE) -// are emitted; LockForNoKeyUpdate and LockForKeyShare are silently dropped. -// - SQLite: the entire clause is silently dropped (SQLite has no row-level locking). -// - NoWait / SkipLocked are supported by PostgreSQL and MySQL 8.0+; silently -// dropped for SQLite. +// - MySQL: LockForUpdate and LockForShare are emitted; PostgreSQL-only modes +// return ErrUnsupportedFeature. +// - SQLite: all row-locking modes return ErrUnsupportedFeature. // // Example: // @@ -119,14 +118,7 @@ func (b *SelectBuilder) For(strength LockStrength, opts ...LockOption) *SelectBu // // Dialect behaviour: // - PostgreSQL: rendered as DISTINCT ON (cols). -// - MySQL / SQLite: SupportsDistinctOn() is false; the DISTINCT ON columns -// are silently dropped and the query degrades to SELECT DISTINCT. -// -// Warning: the degraded form is semantically different. SELECT DISTINCT ON -// deduplicates within each DISTINCT ON group (returning one row per group); -// SELECT DISTINCT deduplicates across all selected columns. The result set -// will differ in most real queries, so portable code should avoid DistinctOn -// or handle the dialect difference explicitly. +// - MySQL / SQLite: Build returns ErrUnsupportedFeature. func (b *SelectBuilder) DistinctOn(cols ...expr.SelectableColumn) *SelectBuilder { cp := *b cp.distinct = true @@ -135,8 +127,8 @@ func (b *SelectBuilder) DistinctOn(cols ...expr.SelectableColumn) *SelectBuilder } // ForUpdate appends FOR UPDATE to the query, locking selected rows against -// concurrent updates. Supported by PostgreSQL and MySQL; silently dropped for -// SQLite, which uses file-level locking only. +// concurrent updates. Unsupported dialects cause Build to return +// ErrUnsupportedFeature. // // ForUpdate is a convenience wrapper around For(LockForUpdate). func (b *SelectBuilder) ForUpdate() *SelectBuilder { @@ -145,7 +137,8 @@ func (b *SelectBuilder) ForUpdate() *SelectBuilder { // ForShare appends FOR SHARE (PostgreSQL) / LOCK IN SHARE MODE (MySQL) to // the query, locking rows for read while allowing other readers. -// PostgreSQL and MySQL only — SQLite silently drops the clause. +// PostgreSQL and MySQL only; unsupported dialects cause Build to return +// ErrUnsupportedFeature. // // ForShare is a convenience wrapper around For(LockForShare). func (b *SelectBuilder) ForShare() *SelectBuilder { @@ -154,8 +147,8 @@ func (b *SelectBuilder) ForShare() *SelectBuilder { // ForNoKeyUpdate appends FOR NO KEY UPDATE to the query. This PostgreSQL-specific // lock mode is weaker than FOR UPDATE: it does not block INSERT of child rows that -// reference this row via a foreign key. Silently dropped for dialects that do not -// support this locking mode (e.g. MySQL, SQLite). +// reference this row via a foreign key. Unsupported dialects cause Build to +// return ErrUnsupportedFeature. // // ForNoKeyUpdate is a convenience wrapper around For(LockForNoKeyUpdate). func (b *SelectBuilder) ForNoKeyUpdate() *SelectBuilder { @@ -164,8 +157,8 @@ func (b *SelectBuilder) ForNoKeyUpdate() *SelectBuilder { // ForKeyShare appends FOR KEY SHARE to the query. This PostgreSQL-specific // lock mode is the weakest row lock: it only blocks DELETE and FOR UPDATE -// operations that would delete or change key values. Silently dropped for -// dialects that do not support this locking mode (e.g. MySQL, SQLite). +// operations that would delete or change key values. Unsupported dialects cause +// Build to return ErrUnsupportedFeature. // // ForKeyShare is a convenience wrapper around For(LockForKeyShare). func (b *SelectBuilder) ForKeyShare() *SelectBuilder { @@ -198,16 +191,14 @@ func (b *SelectBuilder) ForKeyShare() *SelectBuilder { // Of works with all four lock modes (LockForUpdate, LockForNoKeyUpdate, // LockForShare, LockForKeyShare). Dialect-specific behaviour: // - PostgreSQL: all specified tables are emitted for all four lock modes. -// - MySQL: all specified tables are emitted for FOR UPDATE (MySQL 8.0+). -// For LOCK IN SHARE MODE (LockForShare on MySQL), OF is not supported and is -// silently dropped. LockForNoKeyUpdate and LockForKeyShare are dropped entirely -// on MySQL, so OF has no effect for those modes. -// - SQLite: OF is silently ignored (SQLite has no row-level locking). +// - MySQL: lock table lists return ErrUnsupportedFeature. +// - SQLite: Build returns ErrUnsupportedFeature. // // The call order relative to For/ForUpdate/ForShare does not matter. func (b *SelectBuilder) Of(tables ...TableSource) *SelectBuilder { cp := *b cp.lockOf = append(append([]TableSource(nil), cp.lockOf...), tables...) + cp.lockOfSet = true return &cp } @@ -217,10 +208,7 @@ func (b *SelectBuilder) Of(tables ...TableSource) *SelectBuilder { // // CTE support requires SupportsCTE() on the dialect. All built-in dialects // (PostgreSQL, MySQL 8.0+, SQLite 3.8.3+) return true. When building against a -// dialect where SupportsCTE() is false, the WITH clause is omitted from the -// output SQL. Any CTERef used in From() or Join() remains as a plain table -// name, which will cause a runtime database error (unknown table). This is -// intentional: failing loudly is safer than silently returning wrong results. +// dialect where SupportsCTE() is false, Build returns ErrUnsupportedFeature. // // Example: // @@ -244,10 +232,7 @@ func (b *SelectBuilder) With(name string, sub *SelectBuilder) *SelectBuilder { // // CTE support requires SupportsCTE() on the dialect. All built-in dialects // (PostgreSQL, MySQL 8.0+, SQLite 3.8.3+) return true. When building against a -// dialect where SupportsCTE() is false, the WITH RECURSIVE clause is omitted -// from the output SQL. Any CTERef used in From() or Join() remains as a plain -// table name, producing a runtime database error (unknown table). This is -// intentional: failing loudly is safer than silently returning wrong results. +// dialect where SupportsCTE() is false, Build returns ErrUnsupportedFeature. // // Example — traverse an org-chart by manager_id: // @@ -318,7 +303,8 @@ func (b *SelectBuilder) InnerJoin(t TableSource, on expr.Expression) *SelectBuil return &cp } -// RightJoin adds a RIGHT JOIN clause. +// RightJoin adds a RIGHT JOIN clause. Build returns ErrUnsupportedFeature when +// the selected dialect cannot guarantee RIGHT JOIN support. func (b *SelectBuilder) RightJoin(t TableSource, on expr.Expression) *SelectBuilder { cp := *b cp.joins = append(append([]joinClause(nil), cp.joins...), joinClause{kind: joinRight, table: t, on: on}) @@ -327,13 +313,8 @@ func (b *SelectBuilder) RightJoin(t TableSource, on expr.Expression) *SelectBuil // FullJoin adds a FULL JOIN clause. // FULL JOIN requires SupportsFullJoin() on the dialect. When building against a -// dialect where SupportsFullJoin() is false (MySQL, SQLite), the join is -// silently dropped from the output SQL. -// -// Warning: dropping a FULL JOIN is a semantic change, not just a syntax -// difference. Rows that would have been included via the outer side of the join -// are omitted entirely. Do not rely on the silent-drop behaviour for portable -// code; use a dialect check or restructure the query for non-PostgreSQL targets. +// dialect where SupportsFullJoin() is false (MySQL, SQLite), Build returns +// ErrUnsupportedFeature. func (b *SelectBuilder) FullJoin(t TableSource, on expr.Expression) *SelectBuilder { cp := *b cp.joins = append(append([]joinClause(nil), cp.joins...), joinClause{kind: joinFull, table: t, on: on}) @@ -405,19 +386,36 @@ func (b *SelectBuilder) Offset(n int) *SelectBuilder { return &cp } -// Build renders the query to a SQL string and bound arg slice. -func (b *SelectBuilder) Build(d dialect.Dialect) (string, []any) { - ctx := expr.NewBuildContext(d) - return b.buildWith(ctx), ctx.Args() +// Build renders the query to a SQL string and bound arg slice. Validation or +// dialect-capability failures return empty SQL and no arguments. +func (b *SelectBuilder) Build(d dialect.Dialect) (string, []any, error) { + ctx, err := newBuildContext(d) + if err != nil { + return buildFailure("build_select", err) + } + sql, err := b.buildWith(ctx) + if err != nil { + return buildFailure("build_select", err) + } + return sql, ctx.Args(), nil } // buildWith renders the SELECT statement into an existing BuildContext. // This is called by Build and by subquery expressions to share parameter numbering. -func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { +func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) (string, error) { + if b == nil { + return "", NewError(CodeBuildValidation, "build_select", "select builder is nil") + } + if b.limit < 0 || b.offset < 0 { + return "", NewError(CodeBuildValidation, "build_select", "select limit and offset must not be negative") + } var sb strings.Builder - // WITH [RECURSIVE] (CTEs) — only emitted for dialects that support CTEs. - if len(b.ctes) > 0 && ctx.Dialect().SupportsCTE() { + // WITH [RECURSIVE] (CTEs). + if len(b.ctes) > 0 { + if !ctx.Dialect().SupportsCTE() { + return "", NewError(CodeUnsupportedFeature, "build_select", "common table expressions are not supported by this dialect") + } hasRecursive := false for _, cte := range b.ctes { if cte.anchor != nil { @@ -434,15 +432,37 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(cte.name)) + name, err := ctx.Quote(cte.name) + if err != nil { + return "", err + } + sb.WriteString(name) sb.WriteString(" AS (") if cte.anchor != nil { + if cte.recursive == nil { + return "", NewError(CodeBuildValidation, "build_select", "recursive common table expression contains a nil term") + } // Recursive CTE: anchor UNION ALL recursive - sb.WriteString(cte.anchor.buildWith(ctx)) + anchor, err := cte.anchor.buildWith(ctx) + if err != nil { + return "", err + } + sb.WriteString(anchor) sb.WriteString(" UNION ALL ") - sb.WriteString(cte.recursive.buildWith(ctx)) + recursive, err := cte.recursive.buildWith(ctx) + if err != nil { + return "", err + } + sb.WriteString(recursive) } else { - sb.WriteString(cte.sub.buildWith(ctx)) + if cte.sub == nil { + return "", NewError(CodeBuildValidation, "build_select", "common table expression contains a nil select") + } + sub, err := cte.sub.buildWith(ctx) + if err != nil { + return "", err + } + sb.WriteString(sub) } sb.WriteString(")") } @@ -452,7 +472,10 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { // SELECT [DISTINCT [ON (cols)]] sb.WriteString("SELECT ") if b.distinct { - if len(b.distinctOn) > 0 && ctx.Dialect().SupportsDistinctOn() { + if len(b.distinctOn) > 0 { + if !ctx.Dialect().SupportsDistinctOn() { + return "", NewError(CodeUnsupportedFeature, "build_select", "distinct on is not supported by this dialect") + } // PostgreSQL DISTINCT ON: SELECT DISTINCT ON (col1, col2) ... // Use distinctColSQL (not selectColSQL) to avoid emitting "AS alias" // when the caller passes an AliasedCol; DISTINCT ON does not accept aliases. @@ -461,7 +484,11 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(distinctColSQL(ctx, c)) + col, err := distinctColSQL(ctx, c) + if err != nil { + return "", err + } + sb.WriteString(col) } sb.WriteString(") ") } else { @@ -471,71 +498,109 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { if len(b.cols) == 0 { sb.WriteString("*") } else { - // Window functions are dropped for dialects that do not support them. - // AliasedCol is unwrapped one level (via Unwrap()) so that - // expr.ColAs(expr.RowNumber(), "rn") is also correctly gated. - written := 0 - for _, c := range b.cols { + // AliasedCol is unwrapped one level (via Unwrap()) so that window + // capability checks also apply to aliased window expressions. + for i, c := range b.cols { + if isNilValue(c) { + return "", NewError(CodeBuildValidation, "build_select", "select list contains a nil column") + } if !ctx.Dialect().SupportsWindowFunctions() && isWindowFunction(c) { - continue + return "", NewError(CodeUnsupportedFeature, "build_select", "window functions are not supported by this dialect") } - if written > 0 { + if i > 0 { sb.WriteString(", ") } - sb.WriteString(selectColSQL(ctx, c)) - written++ - } - if written == 0 { - // All selected columns were window functions dropped by the dialect. - // Fall back to SELECT * to produce a runnable query rather than a - // syntax error. Note: SELECT * returns all table columns, including - // any that were intentionally excluded from the original SELECT list. - // Callers that rely on column restriction for correctness or data - // access control must check d.SupportsWindowFunctions() before - // building the query in this configuration. - sb.WriteString("*") + col, err := selectColSQL(ctx, c) + if err != nil { + return "", err + } + sb.WriteString(col) } } // FROM if b.from != nil { + if isNilValue(b.from) { + return "", NewError(CodeBuildValidation, "build_select", "from source is nil") + } sb.WriteString(" FROM ") if sq, ok := b.from.(*SubquerySource); ok { + if sq == nil || sq.sub == nil { + return "", NewError(CodeBuildValidation, "build_select", "from subquery is nil") + } // Subquery: (SELECT ...) AS alias — render into the same context. sb.WriteString("(") - sb.WriteString(sq.sub.buildWith(ctx)) + sub, err := sq.sub.buildWith(ctx) + if err != nil { + return "", err + } + sb.WriteString(sub) sb.WriteString(") AS ") - sb.WriteString(ctx.Quote(sq.alias)) + alias, err := ctx.Quote(sq.alias) + if err != nil { + return "", err + } + sb.WriteString(alias) } else { - sb.WriteString(ctx.Quote(b.from.GrizTableName())) + table, err := quoteTableSource(ctx, b.from) + if err != nil { + return "", err + } + sb.WriteString(table) if b.from.GrizTableAlias() != b.from.GrizTableName() { sb.WriteString(" AS ") - sb.WriteString(ctx.Quote(b.from.GrizTableAlias())) + alias, err := ctx.Quote(b.from.GrizTableAlias()) + if err != nil { + return "", err + } + sb.WriteString(alias) } } } - // JOINs — FULL JOIN is silently dropped for dialects that do not support it. + // JOINs. for _, j := range b.joins { + if j.kind == joinRight && !ctx.Dialect().SupportsRightJoin() { + return "", NewError(CodeUnsupportedFeature, "build_select", "right join is not supported by this dialect") + } if j.kind == joinFull && !ctx.Dialect().SupportsFullJoin() { - continue + return "", NewError(CodeUnsupportedFeature, "build_select", "full join is not supported by this dialect") + } + if j.kind != joinCross && isNilValue(j.on) { + return "", NewError(CodeBuildValidation, "build_select", "join predicate is nil") } sb.WriteString(" ") sb.WriteString(string(j.kind)) sb.WriteString(" ") - sb.WriteString(ctx.Quote(j.table.GrizTableName())) + table, err := quoteTableSource(ctx, j.table) + if err != nil { + return "", err + } + sb.WriteString(table) if j.table.GrizTableAlias() != j.table.GrizTableName() { sb.WriteString(" AS ") - sb.WriteString(ctx.Quote(j.table.GrizTableAlias())) + alias, err := ctx.Quote(j.table.GrizTableAlias()) + if err != nil { + return "", err + } + sb.WriteString(alias) } - if j.on != nil { + if j.kind != joinCross { sb.WriteString(" ON ") - sb.WriteString(j.on.ToSQL(ctx)) + on, err := j.on.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(on) } } // WHERE - sb.WriteString(buildWhere(ctx, b.where)) + where, err := buildWhere(ctx, b.where) + if err != nil { + return "", err + } + sb.WriteString(where) // GROUP BY // Use distinctColSQL (not selectColSQL) to avoid emitting "AS alias" when @@ -546,46 +611,89 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(distinctColSQL(ctx, c)) + col, err := distinctColSQL(ctx, c) + if err != nil { + return "", err + } + sb.WriteString(col) } } // HAVING if b.having != nil { + if isNilValue(b.having) { + return "", NewError(CodeBuildValidation, "build_select", "having expression is nil") + } sb.WriteString(" HAVING ") - sb.WriteString(b.having.ToSQL(ctx)) + having, err := b.having.RenderSQL(ctx) + if err != nil { + return "", err + } + sb.WriteString(having) } // ORDER BY - sb.WriteString(buildOrderBy(ctx, b.orderBy)) + orderBy, err := buildOrderBy(ctx, b.orderBy) + if err != nil { + return "", err + } + sb.WriteString(orderBy) // LIMIT if b.limit > 0 { - fmt.Fprintf(&sb, " LIMIT %d", b.limit) + _, _ = fmt.Fprintf(&sb, " LIMIT %d", b.limit) } // OFFSET if b.offset > 0 { - fmt.Fprintf(&sb, " OFFSET %d", b.offset) + _, _ = fmt.Fprintf(&sb, " OFFSET %d", b.offset) } - // Locking clauses — only emitted for dialects that support row-level locking. - if b.lockStrength != "" && ctx.Dialect().SupportsForUpdate() { + // Locking clauses. + if b.lockStrength != "" { + switch b.lockStrength { + case LockForUpdate, LockForNoKeyUpdate, LockForShare, LockForKeyShare: + default: + return "", NewError(CodeBuildValidation, "build_select", "row-lock strength is invalid") + } + seenOpts := make(map[LockOption]struct{}, len(b.lockOpts)) + for _, opt := range b.lockOpts { + if opt != NoWait && opt != SkipLocked { + return "", NewError(CodeBuildValidation, "build_select", "row-lock option is invalid") + } + if _, ok := seenOpts[opt]; ok { + return "", NewError(CodeBuildValidation, "build_select", "row-lock option is duplicated") + } + seenOpts[opt] = struct{}{} + } + if b.lockOfSet && len(b.lockOf) == 0 { + return "", NewError(CodeBuildValidation, "build_select", "row-lock table list is empty") + } + if !ctx.Dialect().SupportsForUpdate() { + return "", NewError(CodeUnsupportedFeature, "build_select", "row locking is not supported by this dialect") + } switch b.lockStrength { case LockForNoKeyUpdate, LockForKeyShare: // PostgreSQL-only modes: gate on SupportsForNoKeyUpdate. if !ctx.Dialect().SupportsForNoKeyUpdate() { - break + return "", NewError(CodeUnsupportedFeature, "build_select", "requested row-lock strength is not supported by this dialect") } sb.WriteString(" " + string(b.lockStrength)) // OF table list: PostgreSQL supports it for all modes. if len(b.lockOf) > 0 { sb.WriteString(" OF ") for i, t := range b.lockOf { + if err := b.validateLockSource(t); err != nil { + return "", err + } if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(t.GrizTableAlias())) + alias, err := ctx.Quote(t.GrizTableAlias()) + if err != nil { + return "", err + } + sb.WriteString(alias) } } // NOWAIT / SKIP LOCKED modifiers. @@ -597,13 +705,23 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { // OF table list: only emitted when the dialect declares support for it // (e.g. PostgreSQL FOR SHARE). MySQL's LOCK IN SHARE MODE does not // accept an OF clause, so SupportsForShareOf() returns false there. - if len(b.lockOf) > 0 && ctx.Dialect().SupportsForShareOf() { + if len(b.lockOf) > 0 && !ctx.Dialect().SupportsForShareOf() { + return "", NewError(CodeUnsupportedFeature, "build_select", "row-lock table lists are not supported by this dialect") + } + if len(b.lockOf) > 0 { sb.WriteString(" OF ") for i, t := range b.lockOf { + if err := b.validateLockSource(t); err != nil { + return "", err + } if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(t.GrizTableAlias())) + alias, err := ctx.Quote(t.GrizTableAlias()) + if err != nil { + return "", err + } + sb.WriteString(alias) } } // NOWAIT / SKIP LOCKED modifiers. @@ -612,14 +730,24 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { } default: // LockForUpdate (and any future modes) sb.WriteString(" " + string(b.lockStrength)) - // OF table list: supported by PostgreSQL and MySQL 8.0+ FOR UPDATE. + if len(b.lockOf) > 0 && !ctx.Dialect().SupportsForShareOf() { + return "", NewError(CodeUnsupportedFeature, "build_select", "row-lock table lists are not supported by this dialect") + } + // OF table lists are PostgreSQL-compatible. if len(b.lockOf) > 0 { sb.WriteString(" OF ") for i, t := range b.lockOf { + if err := b.validateLockSource(t); err != nil { + return "", err + } if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(t.GrizTableAlias())) + alias, err := ctx.Quote(t.GrizTableAlias()) + if err != nil { + return "", err + } + sb.WriteString(alias) } } // NOWAIT / SKIP LOCKED modifiers. @@ -627,9 +755,27 @@ func (b *SelectBuilder) buildWith(ctx *expr.BuildContext) string { sb.WriteString(" " + string(opt)) } } + } else if b.lockOfSet { + return "", NewError(CodeBuildValidation, "build_select", "row-lock table list requires a lock mode") } - return sb.String() + return sb.String(), nil +} + +func (b *SelectBuilder) validateLockSource(target TableSource) error { + if isNilValue(target) { + return NewError(CodeBuildValidation, "build_select", "row-lock table source is nil") + } + wantAlias := target.GrizTableAlias() + if b.from != nil && !isNilValue(b.from) && b.from.GrizTableAlias() == wantAlias { + return nil + } + for _, join := range b.joins { + if !isNilValue(join.table) && join.table.GrizTableAlias() == wantAlias { + return nil + } + } + return NewError(CodeBuildValidation, "build_select", "row-lock table source is not active in the query") } // isWindowFunction reports whether c is a window function expression. @@ -661,11 +807,14 @@ func isWindowExprType(c expr.SelectableColumn) bool { // selectColSQL produces the SQL fragment for a selectable column. // For aggregate expressions (COUNT, SUM, …) that implement expr.Expression, -// ToSQL is called directly so the aggregate function syntax is preserved. +// RenderSQL is called directly so the aggregate function syntax is preserved. // For plain columns the standard quoted "table"."col" form is returned. -func selectColSQL(ctx *expr.BuildContext, c expr.SelectableColumn) string { +func selectColSQL(ctx *expr.BuildContext, c expr.SelectableColumn) (string, error) { + if isNilValue(c) { + return "", NewError(CodeBuildValidation, "render_select_column", "selectable column is nil") + } if e, ok := c.(expr.Expression); ok { - return e.ToSQL(ctx) + return e.RenderSQL(ctx) } return ctx.ColRef(c.TableName(), c.ColumnName()) } @@ -673,8 +822,11 @@ func selectColSQL(ctx *expr.BuildContext, c expr.SelectableColumn) string { // distinctColSQL produces the SQL fragment for a column in non-SELECT positions // such as GROUP BY and DISTINCT ON where an AS alias clause is invalid. // AliasedCol values are unwrapped one level so only the bare column reference -// is emitted; all other expression types are rendered via ToSQL as usual. -func distinctColSQL(ctx *expr.BuildContext, c expr.SelectableColumn) string { +// is emitted; all other expression types are rendered via RenderSQL as usual. +func distinctColSQL(ctx *expr.BuildContext, c expr.SelectableColumn) (string, error) { + if isNilValue(c) { + return "", NewError(CodeBuildValidation, "render_select_column", "selectable column is nil") + } // Unwrap one level of AliasedCol so we render the inner column, not the alias. type unwrapper interface{ Unwrap() expr.SelectableColumn } if u, ok := c.(unwrapper); ok { diff --git a/query/setop.go b/query/setop.go index ff9e542..605ca7b 100644 --- a/query/setop.go +++ b/query/setop.go @@ -16,7 +16,7 @@ import ( // active := query.Select(UsersT.Email).From(UsersT).Where(UsersT.Active.IsTrue()) // admin := query.Select(AdminsT.Email).From(AdminsT) // -// sql, args := active.Union(admin). +// sql, args, err := active.Union(admin). // OrderBy(UsersT.Email.Asc()). // Build(dialect.Postgres) // // (SELECT "users"."email" FROM "users" WHERE "users"."active" = $1) @@ -37,13 +37,17 @@ type setPart struct { // buildSetOpOrderBy renders ORDER BY for a set operation, stripping table // qualifiers: only the column name is valid in UNION/INTERSECT/EXCEPT ORDER BY. -func buildSetOpOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) string { +func buildSetOpOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) (string, error) { if len(exprs) == 0 { - return "" + return "", nil } parts := make([]string, len(exprs)) for i, o := range exprs { - parts[i] = o.ToSQLUnqualified(ctx) + part, err := o.RenderSQLUnqualified(ctx) + if err != nil { + return "", err + } + parts[i] = part } s := " ORDER BY " for i, p := range parts { @@ -52,7 +56,7 @@ func buildSetOpOrderBy(ctx *expr.BuildContext, exprs []expr.OrderExpr) string { } s += p } - return s + return s, nil } // ------------------------------------------------------------------- @@ -151,11 +155,29 @@ func (b *SetOpBuilder) Offset(n int) *SetOpBuilder { // ------------------------------------------------------------------- // Build renders the set operation query to a SQL string and bound arg slice. -func (b *SetOpBuilder) Build(d dialect.Dialect) (string, []any) { - ctx := expr.NewBuildContext(d) +func (b *SetOpBuilder) Build(d dialect.Dialect) (string, []any, error) { + ctx, err := newBuildContext(d) + if err != nil { + return buildFailure("build_set_operation", err) + } + if b == nil { + return buildFailure("build_set_operation", NewError(CodeBuildValidation, "build_set_operation", "set operation builder is nil")) + } + if len(b.parts) < 2 { + return buildFailure("build_set_operation", NewError(CodeBuildValidation, "build_set_operation", "set operation requires at least two selects")) + } + if b.limit < 0 || b.offset < 0 { + return buildFailure("build_set_operation", NewError(CodeBuildValidation, "build_set_operation", "set-operation limit and offset must not be negative")) + } var sb strings.Builder for i, part := range b.parts { + if part.sel == nil { + return buildFailure("build_set_operation", NewError(CodeBuildValidation, "build_set_operation", "set operation contains a nil select")) + } + if i > 0 && part.op == "" { + return buildFailure("build_set_operation", NewError(CodeBuildValidation, "build_set_operation", "set operation is missing an operator")) + } if i > 0 { sb.WriteString(" ") sb.WriteString(part.op) @@ -165,20 +187,28 @@ func (b *SetOpBuilder) Build(d dialect.Dialect) (string, []any) { // individual SELECTs carry their own ORDER BY or LIMIT, and is always // syntactically correct for the overall statement. sb.WriteString("(") - sb.WriteString(part.sel.buildWith(ctx)) + component, err := part.sel.buildWith(ctx) + if err != nil { + return buildFailure("build_set_operation", err) + } + sb.WriteString(component) sb.WriteString(")") } // Overall ORDER BY for set operations must use bare column names only // (no table qualifier) — SQL does not allow table-qualified references // in the ORDER BY of a UNION / INTERSECT / EXCEPT. - sb.WriteString(buildSetOpOrderBy(ctx, b.orderBy)) + orderBy, err := buildSetOpOrderBy(ctx, b.orderBy) + if err != nil { + return buildFailure("build_set_operation", err) + } + sb.WriteString(orderBy) if b.limit > 0 { - fmt.Fprintf(&sb, " LIMIT %d", b.limit) + _, _ = fmt.Fprintf(&sb, " LIMIT %d", b.limit) } if b.offset > 0 { - fmt.Fprintf(&sb, " OFFSET %d", b.offset) + _, _ = fmt.Fprintf(&sb, " OFFSET %d", b.offset) } - return sb.String(), ctx.Args() + return sb.String(), ctx.Args(), nil } diff --git a/query/subquery.go b/query/subquery.go index b376a38..f11d567 100644 --- a/query/subquery.go +++ b/query/subquery.go @@ -66,14 +66,28 @@ func (s *SubquerySource) GrizTableAlias() string { return s.alias } type existsExpr struct{ sub *SelectBuilder } -func (e existsExpr) ToSQL(ctx *expr.BuildContext) string { - return "EXISTS (" + e.sub.buildWith(ctx) + ")" +func (e existsExpr) RenderSQL(ctx *expr.BuildContext) (string, error) { + if e.sub == nil { + return "", NewError(CodeBuildValidation, "render_subquery", "exists subquery is nil") + } + sub, err := e.sub.buildWith(ctx) + if err != nil { + return "", err + } + return "EXISTS (" + sub + ")", nil } type notExistsExpr struct{ sub *SelectBuilder } -func (e notExistsExpr) ToSQL(ctx *expr.BuildContext) string { - return "NOT EXISTS (" + e.sub.buildWith(ctx) + ")" +func (e notExistsExpr) RenderSQL(ctx *expr.BuildContext) (string, error) { + if e.sub == nil { + return "", NewError(CodeBuildValidation, "render_subquery", "not-exists subquery is nil") + } + sub, err := e.sub.buildWith(ctx) + if err != nil { + return "", err + } + return "NOT EXISTS (" + sub + ")", nil } type subqueryInExpr struct { @@ -81,10 +95,21 @@ type subqueryInExpr struct { sub *SelectBuilder } -func (e subqueryInExpr) ToSQL(ctx *expr.BuildContext) string { +func (e subqueryInExpr) RenderSQL(ctx *expr.BuildContext) (string, error) { // Use distinctColSQL to strip any AS alias from an AliasedCol; the IN // left-hand side is a column reference, not a SELECT-list position (#131). - return distinctColSQL(ctx, e.col) + " IN (" + e.sub.buildWith(ctx) + ")" + column, err := distinctColSQL(ctx, e.col) + if err != nil { + return "", err + } + if e.sub == nil { + return "", NewError(CodeBuildValidation, "render_subquery", "in subquery is nil") + } + sub, err := e.sub.buildWith(ctx) + if err != nil { + return "", err + } + return column + " IN (" + sub + ")", nil } type subqueryNotInExpr struct { @@ -92,8 +117,19 @@ type subqueryNotInExpr struct { sub *SelectBuilder } -func (e subqueryNotInExpr) ToSQL(ctx *expr.BuildContext) string { +func (e subqueryNotInExpr) RenderSQL(ctx *expr.BuildContext) (string, error) { // Use distinctColSQL to strip any AS alias from an AliasedCol; the NOT IN // left-hand side is a column reference, not a SELECT-list position (#131). - return distinctColSQL(ctx, e.col) + " NOT IN (" + e.sub.buildWith(ctx) + ")" + column, err := distinctColSQL(ctx, e.col) + if err != nil { + return "", err + } + if e.sub == nil { + return "", NewError(CodeBuildValidation, "render_subquery", "not-in subquery is nil") + } + sub, err := e.sub.buildWith(ctx) + if err != nil { + return "", err + } + return column + " NOT IN (" + sub + ")", nil } diff --git a/query/update.go b/query/update.go index 3e493be..706d62e 100644 --- a/query/update.go +++ b/query/update.go @@ -13,10 +13,10 @@ import ( type UpdateBuilder struct { table TableSource sets []setClause // explicit col = val pairs - setStruct any // alternative: set via struct reflection where expr.Expression returning []expr.SelectableColumn limit int // 0 = no limit; MySQL/SQLite only + buildErr error } type setClause struct { @@ -49,7 +49,17 @@ func (b *UpdateBuilder) Set(col string, val any) *UpdateBuilder { // query.Update(UsersT).SetStruct(UserUpdate{Name: ptr("Alice")}) func (b *UpdateBuilder) SetStruct(row any) *UpdateBuilder { cp := *b - cp.setStruct = row + cols, vals, err := structSetsForUpdate(row) + if err != nil { + if cp.buildErr == nil { + cp.buildErr = err + } + return &cp + } + cp.sets = append([]setClause(nil), b.sets...) + for i, col := range cols { + cp.sets = append(cp.sets, setClause{col: col, val: vals[i]}) + } return &cp } @@ -73,8 +83,6 @@ func (b *UpdateBuilder) Returning(cols ...expr.SelectableColumn) *UpdateBuilder } // Limit sets a row limit on the UPDATE (MySQL / SQLite only). -// PostgreSQL does not support LIMIT on UPDATE; this is silently ignored for -// dialects that do not support it. func (b *UpdateBuilder) Limit(n int) *UpdateBuilder { cp := *b cp.limit = n @@ -82,56 +90,81 @@ func (b *UpdateBuilder) Limit(n int) *UpdateBuilder { } // Build renders the UPDATE statement. -func (b *UpdateBuilder) Build(d dialect.Dialect) (string, []any) { - ctx := expr.NewBuildContext(d) +func (b *UpdateBuilder) Build(d dialect.Dialect) (string, []any, error) { + ctx, err := newBuildContext(d) + if err != nil { + return buildFailure("build_update", err) + } + if b == nil { + return buildFailure("build_update", NewError(CodeBuildValidation, "build_update", "update builder is nil")) + } + if b.buildErr != nil { + return buildFailure("build_update", b.buildErr) + } + if b.limit < 0 { + return buildFailure("build_update", NewError(CodeBuildValidation, "build_update", "update limit must not be negative")) + } var sb strings.Builder sb.WriteString("UPDATE ") - sb.WriteString(ctx.Quote(b.table.GrizTableName())) + table, err := quoteTableSource(ctx, b.table) + if err != nil { + return buildFailure("build_update", err) + } + sb.WriteString(table) sb.WriteString(" SET ") - // Collect all SET clauses: explicit sets + struct sets + // Collect all SET clauses. allSets := append([]setClause(nil), b.sets...) - if b.setStruct != nil { - cols, vals, err := structSetsForUpdate(b.setStruct) - if err != nil { - return "", nil - } - for i, c := range cols { - allSets = append(allSets, setClause{col: c, val: vals[i]}) - } - } if len(allSets) == 0 { - return "", nil + return buildFailure("build_update", NewError(CodeBuildValidation, "build_update", "update contains no assignments")) } for i, s := range allSets { if i > 0 { sb.WriteString(", ") } - sb.WriteString(ctx.Quote(s.col)) + column, err := ctx.Quote(s.col) + if err != nil { + return buildFailure("build_update", err) + } + sb.WriteString(column) sb.WriteString(" = ") sb.WriteString(ctx.Add(s.val)) } - sb.WriteString(buildWhere(ctx, b.where)) + where, err := buildWhere(ctx, b.where) + if err != nil { + return buildFailure("build_update", err) + } + sb.WriteString(where) - if b.limit > 0 && d.SupportsLimitOnMutate() { - fmt.Fprintf(&sb, " LIMIT %d", b.limit) + if b.limit > 0 { + if !d.SupportsLimitOnMutate() { + return buildFailure("build_update", NewError(CodeUnsupportedFeature, "build_update", "update limits are not supported by this dialect")) + } + _, _ = fmt.Fprintf(&sb, " LIMIT %d", b.limit) } - if len(b.returning) > 0 && d.SupportsReturning() { + if len(b.returning) > 0 { + if !d.SupportsReturning() { + return buildFailure("build_update", NewError(CodeUnsupportedFeature, "build_update", "returning is not supported by this dialect")) + } sb.WriteString(" RETURNING ") for i, c := range b.returning { if i > 0 { sb.WriteString(", ") } - sb.WriteString(selectColSQL(ctx, c)) + column, err := selectColSQL(ctx, c) + if err != nil { + return buildFailure("build_update", err) + } + sb.WriteString(column) } } - return sb.String(), ctx.Args() + return sb.String(), ctx.Args(), nil } // ------------------------------------------------------------------- @@ -157,6 +190,7 @@ func structSetsForUpdate(row any) (cols []string, vals []any, err error) { return nil, nil, fmt.Errorf("structSetsForUpdate: expected struct, got %s", rv.Kind()) } rt := rv.Type() + seen := make(map[string]struct{}) for i := 0; i < rt.NumField(); i++ { field := rt.Field(i) fv := rv.Field(i) @@ -164,7 +198,23 @@ func structSetsForUpdate(row any) (cols []string, vals []any, err error) { if tag == "" || tag == "-" { continue } - colName := strings.SplitN(tag, ",", 2)[0] + if field.PkgPath != "" || !fv.CanInterface() { + return nil, nil, fmt.Errorf("structSetsForUpdate: tagged field is not exported") + } + parts := strings.Split(tag, ",") + colName := parts[0] + if colName == "" { + return nil, nil, fmt.Errorf("structSetsForUpdate: empty db tag") + } + for _, option := range parts[1:] { + if option != "" && option != "omitempty" { + return nil, nil, fmt.Errorf("structSetsForUpdate: unsupported db tag option") + } + } + if _, ok := seen[colName]; ok { + return nil, nil, fmt.Errorf("structSetsForUpdate: duplicate db tag") + } + seen[colName] = struct{}{} if fv.Kind() == reflect.Ptr && fv.IsNil() { continue }