diff --git a/cmd/gooq-gen/main.go b/cmd/gooq-gen/main.go index 77beb3b..46beeab 100644 --- a/cmd/gooq-gen/main.go +++ b/cmd/gooq-gen/main.go @@ -1,11 +1,11 @@ -// Command gooq-gen generates typed table accessors for the jooq query builder by +// Command gooq-gen generates typed table accessors for the gooq query builder by // introspecting a live database schema through the standard information_schema // catalog. For each table it writes a ".gen.go" file containing an // embedded gooq.TableImpl, one typed Field per column, an As method for // aliasing, key metadata accessors, and a package-level accessor variable. // // The command is a thin wrapper around the github.com/cgardev/gooq/codegen -// package. The jooq library itself imports no database driver. To run this +// package. The gooq library itself imports no database driver. To run this // command, the caller builds it with their driver blank-imported, for example: // // import _ "github.com/jackc/pgx/v5/stdlib" // for the "postgres" driver diff --git a/codegen/emit.go b/codegen/emit.go index 8a9e5e9..a26fbc1 100644 --- a/codegen/emit.go +++ b/codegen/emit.go @@ -8,7 +8,7 @@ import ( "text/template" ) -// gooqImport is the import path of the runtime jooq package whose field types +// gooqImport is the import path of the runtime gooq package whose field types // the generated code references. const gooqImport = "github.com/cgardev/gooq" @@ -31,7 +31,7 @@ type templateColumn struct { Field string // Type is the Go field type, such as "gooq.NumericField[int64]". Type string - // Constructor is the jooq constructor for the field, such as + // Constructor is the gooq constructor for the field, such as // "gooq.NewNumericField[int64]". Constructor string // Column is the unqualified database column name. diff --git a/codegen/emit_test.go b/codegen/emit_test.go index 21496c7..baa8610 100644 --- a/codegen/emit_test.go +++ b/codegen/emit_test.go @@ -311,7 +311,7 @@ func TestEmitTableTypeOverrideBySQLType(t *testing.T) { } // TestEmitTableCompiles asserts that the emitted source parses as valid Go. A -// full type check would require resolving the jooq import, so parsing is used as +// full type check would require resolving the gooq import, so parsing is used as // the lightweight proof of syntactic validity. func TestEmitTableCompiles(t *testing.T) { table := TableSchema{ diff --git a/codegen/introspect.go b/codegen/introspect.go index e664f03..9a3a325 100644 --- a/codegen/introspect.go +++ b/codegen/introspect.go @@ -1,5 +1,5 @@ // Package codegen introspects a PostgreSQL database and generates typed table -// accessors compatible with the jooq query builder package. +// accessors compatible with the gooq query builder package. // // The package depends only on the Go standard library. Database drivers are // intentionally not imported; callers must blank-import the driver appropriate diff --git a/codegen/typemap.go b/codegen/typemap.go index bcb252b..aa3f524 100644 --- a/codegen/typemap.go +++ b/codegen/typemap.go @@ -2,7 +2,7 @@ package codegen import "strings" -// goMapping describes how a SQL column type maps onto the jooq field types: the +// goMapping describes how a SQL column type maps onto the gooq field types: the // Go field type used in the generated struct, the constructor that builds it, // and any additional package imports the field type requires. type goMapping struct { @@ -11,7 +11,7 @@ type goMapping struct { imports []string } -// typeMapping captures the refined non-nullable jooq field mapping for a SQL +// typeMapping captures the refined non-nullable gooq field mapping for a SQL // data type together with the information needed to derive its nullable mapping. // // A nullable column is mapped in one of three ways, in order of precedence: @@ -54,7 +54,7 @@ func normalizeType(dataType string) string { } // mappingFor translates a normalized SQL data type into its mapping, capturing -// both the non-nullable jooq field descriptor and the underlying Go element +// both the non-nullable gooq field descriptor and the underlying Go element // type. Recognized integer types map to a NumericField[int64], floating and // fixed-point types to a NumericField[float64], boolean types to a Field[bool], // temporal types to a Field[time.Time], binary types to a Field[[]byte], JSON diff --git a/doc.go b/doc.go index babdea9..de586e3 100644 --- a/doc.go +++ b/doc.go @@ -1,4 +1,4 @@ -// Package jooq provides a type-safe, fluent SQL query builder for Go inspired +// Package gooq provides a type-safe, fluent SQL query builder for Go inspired // by the Java library jOOQ. It combines parametric Field[T] columns, positional // RecordN row types, step interfaces that make the clause order a compile-time // concern, and runtime dialect translation from a single abstract syntax tree. diff --git a/errors.go b/errors.go index 48e4925..6dc528c 100644 --- a/errors.go +++ b/errors.go @@ -4,18 +4,18 @@ import "errors" // ErrTooManyRows is returned by FetchOne and FetchSingle when a query yields // more rows than the caller expected. -var ErrTooManyRows = errors.New("jooq: query returned more than one row") +var ErrTooManyRows = errors.New("gooq: query returned more than one row") // ErrReturningUnsupported is recorded when a RETURNING clause is requested for a // dialect that does not support it. Both supported dialects (PostgreSQL and // SQLite) render RETURNING natively, so this sentinel exists as a defensive // guard for any dialect whose supportsReturning reports false. -var ErrReturningUnsupported = errors.New("jooq: RETURNING is not supported by this dialect") +var ErrReturningUnsupported = errors.New("gooq: RETURNING is not supported by this dialect") // ErrEmptyInsert is recorded when an INSERT statement has neither columns nor a // DEFAULT VALUES marker. -var ErrEmptyInsert = errors.New("jooq: INSERT has no columns or values") +var ErrEmptyInsert = errors.New("gooq: INSERT has no columns or values") // ErrColumnValueMismatch is recorded when an inserted row has a different number // of values than there are columns. -var ErrColumnValueMismatch = errors.New("jooq: column count does not match value count") +var ErrColumnValueMismatch = errors.New("gooq: column count does not match value count") diff --git a/example/main.go b/example/main.go index 6711977..11d74ce 100644 --- a/example/main.go +++ b/example/main.go @@ -1,4 +1,4 @@ -// Command example demonstrates the jooq-for-go query builder against the +// Command example demonstrates the gooq-for-go query builder against the // generated table accessors in ./internal/db. It renders representative queries // rather than connecting to a database, so it runs with no external dependency // and showcases the single-AST, render-per-dialect design. diff --git a/fakedb_test.go b/fakedb_test.go index 6a1ac31..b6f6b09 100644 --- a/fakedb_test.go +++ b/fakedb_test.go @@ -14,12 +14,12 @@ import ( // *sql.Result values without any external dependency or live database. func init() { - sql.Register("jooqfake", fakeDriver{}) + sql.Register("gooqfake", fakeDriver{}) } // openFakeDB returns a *sql.DB backed by the fake driver. func openFakeDB() *sql.DB { - db, err := sql.Open("jooqfake", "") + db, err := sql.Open("gooqfake", "") if err != nil { panic(err) } @@ -69,7 +69,7 @@ type fakeConn struct{} func (*fakeConn) Prepare(query string) (driver.Stmt, error) { return &fakeStmt{}, nil } func (*fakeConn) Close() error { return nil } func (*fakeConn) Begin() (driver.Tx, error) { - return nil, errors.New("jooqfake: transactions unsupported") + return nil, errors.New("gooqfake: transactions unsupported") } func namedToValues(named []driver.NamedValue) []driver.Value { diff --git a/fetch_into.go b/fetch_into.go index 146249f..27d9e1e 100644 --- a/fetch_into.go +++ b/fetch_into.go @@ -157,7 +157,7 @@ func newRowMapper[S any](rows *sql.Rows) (*rowMapper[S], error) { var zero S structType := reflect.TypeOf(zero) if structType == nil || structType.Kind() != reflect.Struct { - return nil, fmt.Errorf("jooq: FetchInto target %T is not a struct type", zero) + return nil, fmt.Errorf("gooq: FetchInto target %T is not a struct type", zero) } columns, err := rows.Columns() @@ -223,7 +223,7 @@ func (m *rowMapper[S]) columnIndex(column string) (int, error) { return i, nil } } - return 0, fmt.Errorf("jooq: key column %q is not among the selected columns", column) + return 0, fmt.Errorf("gooq: key column %q is not among the selected columns", column) } // scan maps the current row into a new value of S. @@ -282,7 +282,7 @@ func convertKey[K comparable](scanned any) (K, error) { if keyType != nil && source.Type().ConvertibleTo(keyType) { return source.Convert(keyType).Interface().(K), nil } - return zero, fmt.Errorf("jooq: cannot use key value of type %T as key type %T", value, zero) + return zero, fmt.Errorf("gooq: cannot use key value of type %T as key type %T", value, zero) } // dereference unwraps a scan target back to the underlying scanned value. A diff --git a/fetch_into_test.go b/fetch_into_test.go index 2558160..ac6f441 100644 --- a/fetch_into_test.go +++ b/fetch_into_test.go @@ -14,7 +14,7 @@ import ( // FetchMap, FetchGroups) and the typed RETURNING helpers (ReturningInto, // ReturningOneInto). The golden cases assert the rendered RETURNING SQL for both // dialects; the mapping cases run against the package's existing in-process -// "jooqfake" driver (see fakedb_test.go) so the reflection-based scanner is +// "gooqfake" driver (see fakedb_test.go) so the reflection-based scanner is // exercised end to end without any external dependency. func TestReturningIntoRendersStatementSQL(t *testing.T) { @@ -67,7 +67,7 @@ func TestReturningIntoRendersStatementSQL(t *testing.T) { // fakeStatement adapts a column list and row data already queued through // queueRows to the statement interface, so the mapping helpers can run them -// through the jooqfake driver without rendering real SQL. +// through the gooqfake driver without rendering real SQL. type fakeStatement struct{} func (fakeStatement) SQL() (string, []any, error) { return "SELECT mapped", nil, nil } diff --git a/field.go b/field.go index d3d738e..0a2c9df 100644 --- a/field.go +++ b/field.go @@ -64,6 +64,17 @@ type field[T any] struct { func (f field[T]) render(b *builder) { f.expr.render(b) } func (f field[T]) Name() string { return f.name } +// scanTargetProvider is implemented by typed fields that can allocate a scan +// destination of their element type. The dynamic Select uses it to scan each +// projected column into a value of the field's Go type, so dynamic Record rows +// carry the same value types the equivalent typed SelectN projection would +// produce, independent of the driver's native representations. +type scanTargetProvider interface { + scanTarget() any +} + +func (f field[T]) scanTarget() any { return new(T) } + func (f field[T]) cmp(op string, v T) Condition { return newCondition(&binaryPredicate{left: f.expr, op: op, right: bindOf(v)}) } diff --git a/helpers_test.go b/helpers_test.go index f212e2b..f3223bf 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,7 +1,7 @@ package gooq // This file provides hand-written table definitions shaped exactly like the -// output of cmd/jooq-gen, so the tests exercise the same exported code paths a +// output of cmd/gooq-gen, so the tests exercise the same exported code paths a // generated schema would: they embed TableImpl and build columns through the // exported NewField/NewStringField/NewNumericField constructors. diff --git a/integration/generate.go b/integration/generate.go index 19f087c..ca3f71e 100644 --- a/integration/generate.go +++ b/integration/generate.go @@ -1,6 +1,6 @@ -// Package integration contains the PostgreSQL integration tests for the jooq +// Package integration contains the PostgreSQL integration tests for the gooq // query builder. The typed table accessors under internal/db are produced by -// running the jooq code generator against a live PostgreSQL database; they are +// running the gooq code generator against a live PostgreSQL database; they are // not written by hand. // // Regenerate the accessors after changing testdata/schema.sql by running the diff --git a/integration/go.mod b/integration/go.mod index 7af6660..0a0ca86 100644 --- a/integration/go.mod +++ b/integration/go.mod @@ -1,4 +1,4 @@ -// Module integration holds the database integration tests for the jooq library. +// Module integration holds the database integration tests for the gooq library. // It is a SEPARATE Go module on purpose: the core module (github.com/cgardev/gooq) // has zero external dependencies, and keeping testcontainers and the PostgreSQL // driver here ensures that consuming the library never pulls them in. The core diff --git a/integration/harness_test.go b/integration/harness_test.go index 532af35..1263cc8 100644 --- a/integration/harness_test.go +++ b/integration/harness_test.go @@ -177,7 +177,7 @@ func library(t *testing.T) (context.Context, *sql.Tx) { return ctx, tx } -// seed inserts the canonical fixtures through the jooq insert builder. The data +// seed inserts the canonical fixtures through the gooq insert builder. The data // is deliberately varied so the edge-case tests have something meaningful to // query: two authors (one with a JSONB metadata document, one without), three // books with different prices, page counts, print status, JSONB attribute diff --git a/integration/internal/gendb/main.go b/integration/internal/gendb/main.go index 1093307..efb7cbc 100644 --- a/integration/internal/gendb/main.go +++ b/integration/internal/gendb/main.go @@ -1,6 +1,6 @@ // Command gendb regenerates the typed table accessors used by the integration // tests. It starts a disposable PostgreSQL container, applies the authoritative -// schema from testdata/schema.sql, and runs the jooq code generator against the +// schema from testdata/schema.sql, and runs the gooq code generator against the // live database. The generated files are written to internal/db. // // Run it from the integration module root with: diff --git a/integration/postgres_dynamic_select_test.go b/integration/postgres_dynamic_select_test.go new file mode 100644 index 0000000..7c18cee --- /dev/null +++ b/integration/postgres_dynamic_select_test.go @@ -0,0 +1,102 @@ +package integration + +import ( + "testing" + "time" + + "github.com/cgardev/gooq" + "github.com/cgardev/gooq/integration/internal/db" +) + +// postgres_dynamic_select_test.go exercises the dynamic Select end to end +// against the real PostgreSQL container: a projection whose arity is only +// established at run time, the typed Record accessors, and the empty-projection +// SELECT * convenience. + +// TestPostgresDynamicSelectGroupByArity aggregates the seeded books under a +// GROUP BY column set assembled at run time, the motivating use case for the +// dynamic Select: a caller-supplied dimension list of unknown arity. +func TestPostgresDynamicSelectGroupByArity(t *testing.T) { + ctx, tx := library(t) + + groupColumns := []gooq.Field[string]{db.Book.AuthorId} + fields := []gooq.AnyField{gooq.CountStar().As("total")} + groupBy := make([]gooq.AnyField, 0, len(groupColumns)) + for _, column := range groupColumns { + fields = append(fields, column.As("dim_0")) + groupBy = append(groupBy, column) + } + + records, err := gooq.Select(fields...). + From(db.Book). + GroupBy(groupBy...). + OrderBy(db.Book.AuthorId.Asc()). + Fetch(ctx, tx) + noError(t, "dynamic grouped select", err) + + // The three seeded books are written by two distinct authors: Donovan wrote + // one and Kernighan wrote two. + equal(t, "group count", len(records), 2) + + firstAuthor, err := gooq.Value[string](records[0], "dim_0") + noError(t, "read first group dimension", err) + equal(t, "first group author", firstAuthor, authorDonovan) + firstTotal, err := gooq.Value[int64](records[0], "total") + noError(t, "read first group total", err) + equal(t, "first group total", firstTotal, int64(1)) + + secondTotal, err := gooq.Value[int64](records[1], "total") + noError(t, "read second group total", err) + equal(t, "second group total", secondTotal, int64(2)) +} + +// TestPostgresDynamicSelectTypedValues verifies that dynamic Record values +// carry the projected fields' Go element types against a real driver: text as +// string, numeric expressions as float64, and timestamps as time.Time. +func TestPostgresDynamicSelectTypedValues(t *testing.T) { + ctx, tx := library(t) + + publishedAt := gooq.Raw[time.Time]("\"book\".\"published_at\"") + records, err := gooq.Select(db.Book.Title, db.Book.Price, publishedAt.As("published_at")). + From(db.Book). + Where(db.Book.Id.EQ(bookGo)). + Fetch(ctx, tx) + noError(t, "dynamic typed select", err) + equal(t, "row count", len(records), 1) + + title, err := gooq.Value[string](records[0], "title") + noError(t, "read title", err) + equal(t, "title", title, "The Go Programming Language") + + price, err := gooq.ValueAt[float64](records[0], 1) + noError(t, "read price", err) + equal(t, "price", price, 39.99) + + when, ok := records[0].GetByName("published_at").(time.Time) + if !ok { + t.Fatalf("published_at = %#v, want time.Time", records[0].GetByName("published_at")) + } + if !when.Equal(publishedGo) { + t.Errorf("published_at = %v, want %v", when, publishedGo) + } +} + +// TestPostgresDynamicSelectAllColumns verifies the empty-projection SELECT * +// convenience: the column names come from the result set. +func TestPostgresDynamicSelectAllColumns(t *testing.T) { + ctx, tx := library(t) + + records, err := gooq.Select(). + From(db.Book). + Where(db.Book.Id.EQ(bookGo)). + Fetch(ctx, tx) + noError(t, "dynamic select star", err) + equal(t, "row count", len(records), 1) + + if records[0].Size() == 0 { + t.Fatalf("expected the record to carry every table column") + } + title, err := gooq.Value[string](records[0], "title") + noError(t, "read title", err) + equal(t, "title", title, "The Go Programming Language") +} diff --git a/integration/postgres_test.go b/integration/postgres_test.go index edf783b..7a1974a 100644 --- a/integration/postgres_test.go +++ b/integration/postgres_test.go @@ -11,7 +11,7 @@ import ( ) // These tests read top to bottom as small stories. Each opens a seeded library, -// runs one fluent jooq query, and asserts the typed result. The container, the +// runs one fluent gooq query, and asserts the typed result. The container, the // schema, the seeding, and the per-test rollback all live in harness_test.go, so // nothing here repeats the plumbing. diff --git a/integration/sqlite_dynamic_select_test.go b/integration/sqlite_dynamic_select_test.go new file mode 100644 index 0000000..7c54fd5 --- /dev/null +++ b/integration/sqlite_dynamic_select_test.go @@ -0,0 +1,38 @@ +package integration + +import ( + "testing" + + "github.com/cgardev/gooq" + "github.com/cgardev/gooq/integration/internal/db" +) + +// sqlite_dynamic_select_test.go exercises the dynamic Select end to end against +// the pure-Go SQLite database: the run-time projection with GROUP BY and the +// typed Record accessors under a driver with different native representations. + +// TestSQLiteDynamicSelectGroupByArity aggregates the seeded books under a +// run-time GROUP BY column set against SQLite. +func TestSQLiteDynamicSelectGroupByArity(t *testing.T) { + ctx, conn := sqliteLibrary(t) + + records, err := gooq.Select(gooq.CountStar().As("total"), db.Book.AuthorId.As("dim_0")). + From(db.Book). + GroupBy(db.Book.AuthorId). + OrderBy(db.Book.AuthorId.Asc()). + Using(gooq.SQLite()). + Fetch(ctx, conn) + noError(t, "dynamic grouped select", err) + + // The three seeded books are written by two distinct authors: Donovan wrote + // one and Kernighan wrote two. + equal(t, "group count", len(records), 2) + + firstTotal, err := gooq.Value[int64](records[0], "total") + noError(t, "read first group total", err) + equal(t, "first group total", firstTotal, int64(1)) + + secondAuthor, err := gooq.Value[string](records[1], "dim_0") + noError(t, "read second group dimension", err) + equal(t, "second group author", secondAuthor, authorKernighan) +} diff --git a/internal/gen/main.go b/internal/gen/main.go index b8746f5..3176925 100644 --- a/internal/gen/main.go +++ b/internal/gen/main.go @@ -1,6 +1,6 @@ // Command gen produces the repetitive higher-arity Record and Select code for -// the jooq package. It is invoked by the //go:generate directive in doc.go and -// writes record_gen.go and select_gen.go into the parent jooq package directory. +// the gooq package. It is invoked by the //go:generate directive in doc.go and +// writes record_gen.go and select_gen.go into the parent gooq package directory. // // Arities 1 through 5 are written by hand; this program emits 6 through 22. package main diff --git a/record_dynamic.go b/record_dynamic.go new file mode 100644 index 0000000..8e7380a --- /dev/null +++ b/record_dynamic.go @@ -0,0 +1,133 @@ +package gooq + +import ( + "fmt" + "reflect" + "strings" +) + +// Record is a dynamic row whose column count and names are established at run +// time, mirroring jOOQ's untyped org.jooq.Record. It is produced by the dynamic +// Select constructor, whose projection is assembled from a run-time field list. +// The typed Record1 through Record22 remain the preferred row types when the +// projection is known at compile time, because they preserve each column's Go +// type positionally; Record covers the projections they cannot express. +type Record struct { + columns []string + values []any +} + +// Size returns the number of columns in the record. +func (r Record) Size() int { return len(r.values) } + +// Columns returns the projected column names in projection order. Aliased +// fields report their alias and plain columns report their column name; a +// nameless expression (for example a Raw fragment that was not aliased) +// reports the empty string. For a SELECT * projection the names come from the +// result set. +func (r Record) Columns() []string { + out := make([]string, len(r.columns)) + copy(out, r.columns) + return out +} + +// Values returns the column values in projection order. +func (r Record) Values() []any { + out := make([]any, len(r.values)) + copy(out, r.values) + return out +} + +// Get returns the i-th column value (zero-based), or nil when out of range, +// mirroring jOOQ's Record.get(int) with the lenient out-of-range behavior of +// the typed RecordN.Get methods. +func (r Record) Get(i int) any { + if i < 0 || i >= len(r.values) { + return nil + } + return r.values[i] +} + +// GetByName returns the value of the named column, mirroring jOOQ's +// Record.get(String). The name is matched case-insensitively, consistent with +// the column matching of FetchInto; when several columns carry the name, the +// first one wins. It returns nil when no column carries the name, which is +// indistinguishable from a stored SQL NULL — callers that need to tell the two +// apart resolve the position first through Columns and use Get. +func (r Record) GetByName(name string) any { + for i, column := range r.columns { + if strings.EqualFold(column, name) { + return r.values[i] + } + } + return nil +} + +// Value returns the named column of the record converted to T, mirroring +// jOOQ's Record.get(String, Class). The name is matched case-insensitively. A +// SQL NULL yields the zero value of T. It returns an error when no column +// carries the name or when the stored value cannot be converted to T. +func Value[T any](r Record, name string) (T, error) { + for i, column := range r.columns { + if strings.EqualFold(column, name) { + return convertRecordValue[T](r.values[i], fmt.Sprintf("%q", column)) + } + } + var zero T + return zero, fmt.Errorf("gooq: column %q is not among the record's columns", name) +} + +// ValueAt returns the i-th column of the record (zero-based) converted to T, +// mirroring jOOQ's Record.get(int, Class). A SQL NULL yields the zero value of +// T. It returns an error when the index is out of range or when the stored +// value cannot be converted to T. +func ValueAt[T any](r Record, i int) (T, error) { + if i < 0 || i >= len(r.values) { + var zero T + return zero, fmt.Errorf("gooq: column index %d is out of range for a record of %d columns", i, len(r.values)) + } + return convertRecordValue[T](r.values[i], fmt.Sprintf("%d", i)) +} + +// convertRecordValue turns a stored record value into the requested type T: +// a nil value yields the zero value of T, a directly assignable value is +// returned as is, a []byte relaxes to string (covering drivers that report +// text columns as bytes), and numeric kinds convert between one another. +// Anything else is an error rather than a silent reflect conversion, because +// reflect would, for example, turn an integer into the string of its code +// point instead of its decimal representation. +func convertRecordValue[T any](value any, position string) (T, error) { + var zero T + if value == nil { + return zero, nil + } + if typed, ok := value.(T); ok { + return typed, nil + } + if bytes, ok := value.([]byte); ok { + if converted, ok := any(string(bytes)).(T); ok { + return converted, nil + } + } + targetType := reflect.TypeOf(zero) + if targetType != nil && isNumericKind(targetType.Kind()) { + source := reflect.ValueOf(value) + if isNumericKind(source.Kind()) && source.Type().ConvertibleTo(targetType) { + return source.Convert(targetType).Interface().(T), nil + } + } + return zero, fmt.Errorf("gooq: cannot use column %s value of type %T as %T", position, value, zero) +} + +// isNumericKind reports whether the reflect kind is an integer or floating +// point kind, the only kinds convertRecordValue converts between. +func isNumericKind(kind reflect.Kind) bool { + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} diff --git a/select_dynamic.go b/select_dynamic.go new file mode 100644 index 0000000..f5a1d0d --- /dev/null +++ b/select_dynamic.go @@ -0,0 +1,78 @@ +package gooq + +import "database/sql" + +// Select begins a SELECT whose projection is assembled at run time, producing +// dynamic Record rows. It is the counterpart of jOOQ's +// DSL.select(SelectFieldOrAsterisk...) for projections whose arity only becomes +// known at run time — for example a GROUP BY column set derived from a request. +// When the projection is fixed at compile time, the typed Select1 through +// Select22 constructors remain preferable because they preserve each column's +// Go type positionally. +// +// Every projected field that carries a Go element type — every Field[T], +// including aliased fields and the Raw and RawValue escape hatches — is scanned +// into a value of that type, so the Record values match the values the +// equivalent typed projection would produce, independent of the driver's native +// representations. A projected field without a typed backing is scanned into +// the driver's native representation. As in the typed constructors, a SQL NULL +// scanned into a non-nullable element type is an error; nullable columns are +// projected with a pointer or sql.Null* element type. +// +// Passing no fields mirrors the reference's empty-select convenience and +// renders SELECT *. The column names are then taken from the result set and +// every value carries the driver's native representation. +func Select(fields ...AnyField) SelectFromStep[Record] { + if len(fields) == 0 { + return newSelect([]node{&literalNode{sql: "*"}}, scanAllColumns) + } + + projection := make([]node, len(fields)) + columns := make([]string, len(fields)) + for i, f := range fields { + projection[i] = f + columns[i] = f.Name() + } + + scan := func(rows *sql.Rows) (Record, error) { + targets := make([]any, len(fields)) + for i, f := range fields { + if provider, ok := f.(scanTargetProvider); ok { + targets[i] = provider.scanTarget() + } else { + targets[i] = new(any) + } + } + if err := rows.Scan(targets...); err != nil { + return Record{}, err + } + values := make([]any, len(targets)) + for i, target := range targets { + values[i] = dereference(target) + } + return Record{columns: columns, values: values}, nil + } + return newSelect(projection, scan) +} + +// scanAllColumns maps a SELECT * row into a Record: the column names come from +// the result set and every value carries the driver's native representation, +// because an asterisk projection declares no typed fields to scan into. +func scanAllColumns(rows *sql.Rows) (Record, error) { + columns, err := rows.Columns() + if err != nil { + return Record{}, err + } + targets := make([]any, len(columns)) + for i := range targets { + targets[i] = new(any) + } + if err := rows.Scan(targets...); err != nil { + return Record{}, err + } + values := make([]any, len(targets)) + for i, target := range targets { + values[i] = *(target.(*any)) + } + return Record{columns: columns, values: values}, nil +} diff --git a/select_dynamic_test.go b/select_dynamic_test.go new file mode 100644 index 0000000..6eb3e0d --- /dev/null +++ b/select_dynamic_test.go @@ -0,0 +1,228 @@ +package gooq + +import ( + "context" + "database/sql/driver" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +// TestSelectDynamicGolden confirms that the dynamic Select renders exactly the +// SQL its typed SelectN counterpart would render for the same projection, and +// that the run-time projection composes with the full clause chain. +func TestSelectDynamicGolden(t *testing.T) { + t.Run("plain columns render like the typed constructor", func(t *testing.T) { + checkSQL(t, + Select(Book.ID, Book.Title).From(Book), + `SELECT "book"."id", "book"."title" FROM "book"`, + nil, + ) + }) + + t.Run("empty projection renders SELECT *", func(t *testing.T) { + checkSQL(t, + Select().From(Book).Where(Book.Price.GT(10)), + `SELECT * FROM "book" WHERE "book"."price" > $1`, + []any{float64(10)}, + ) + }) + + t.Run("run-time arity composes with aliases and GROUP BY", func(t *testing.T) { + groupColumns := []Field[string]{ + Raw[string]("COALESCE(payload->>'kind', '')"), + Book.Title, + } + fields := []AnyField{CountStar().As("total")} + groupBy := make([]AnyField, 0, len(groupColumns)) + for i, column := range groupColumns { + fields = append(fields, column.As("dim_"+string(rune('0'+i)))) + groupBy = append(groupBy, column) + } + checkSQL(t, + Select(fields...).From(Book).GroupBy(groupBy...), + `SELECT COUNT(*) AS "total", COALESCE(payload->>'kind', '') AS "dim_0", `+ + `"book"."title" AS "dim_1" FROM "book" `+ + `GROUP BY COALESCE(payload->>'kind', ''), "book"."title"`, + nil, + ) + }) + + t.Run("distinct and order compose", func(t *testing.T) { + checkSQL(t, + Select(Book.Title).Distinct().From(Book).OrderBy(Book.Title.Asc()), + `SELECT DISTINCT "book"."title" FROM "book" ORDER BY "book"."title" ASC`, + nil, + ) + }) +} + +// TestSelectDynamicFetch confirms that dynamic Record rows carry the projected +// fields' Go element types, that names resolve through the projection aliases, +// and that the typed accessors convert stored values like the reference +// Record.get(..., Class) contract. +func TestSelectDynamicFetch(t *testing.T) { + resetFake() + db := openFakeDB() + defer db.Close() + + instant := time.Date(2024, 6, 1, 10, 0, 0, 0, time.UTC) + queueRows( + []string{"id", "title", "created_at"}, + []driver.Value{int64(7), "Go", instant}, + ) + + createdAt := NewField[time.Time](Book.TableImpl, "created_at") + records, err := Select(Book.ID, Book.Title.As("title"), createdAt). + From(Book). + Fetch(context.Background(), db) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if len(records) != 1 { + t.Fatalf("records = %d, want 1", len(records)) + } + record := records[0] + + if record.Size() != 3 { + t.Errorf("Size = %d, want 3", record.Size()) + } + if !reflect.DeepEqual(record.Columns(), []string{"id", "title", "created_at"}) { + t.Errorf("Columns = %v", record.Columns()) + } + if got, ok := record.Get(0).(int64); !ok || got != 7 { + t.Errorf("Get(0) = %#v, want int64(7)", record.Get(0)) + } + if got, ok := record.GetByName("TITLE").(string); !ok || got != "Go" { + t.Errorf(`GetByName("TITLE") = %#v, want "Go"`, record.GetByName("TITLE")) + } + if got, ok := record.Get(2).(time.Time); !ok || !got.Equal(instant) { + t.Errorf("Get(2) = %#v, want %v", record.Get(2), instant) + } + if record.Get(3) != nil || record.Get(-1) != nil { + t.Errorf("out-of-range Get should be nil") + } + if record.GetByName("missing") != nil { + t.Errorf(`GetByName("missing") should be nil`) + } + + title, err := Value[string](record, "title") + if err != nil || title != "Go" { + t.Errorf("Value[string] = %q, %v", title, err) + } + idAsInt, err := Value[int32](record, "id") + if err != nil || idAsInt != 7 { + t.Errorf("Value[int32] = %d, %v; want numeric conversion to 7", idAsInt, err) + } + when, err := ValueAt[time.Time](record, 2) + if err != nil || !when.Equal(instant) { + t.Errorf("ValueAt[time.Time] = %v, %v", when, err) + } + if _, err := Value[string](record, "missing"); err == nil { + t.Errorf("Value on an unknown column should error") + } + if _, err := ValueAt[string](record, 9); err == nil { + t.Errorf("ValueAt out of range should error") + } + if _, err := Value[time.Time](record, "title"); err == nil { + t.Errorf("Value with an inconvertible type should error") + } +} + +// TestSelectDynamicFetchAllColumns confirms the SELECT * path: column names +// come from the result set and values carry the driver's native representation, +// with []byte relaxing to string through the typed accessors. +func TestSelectDynamicFetchAllColumns(t *testing.T) { + resetFake() + db := openFakeDB() + defer db.Close() + + queueRows( + []string{"id", "title"}, + []driver.Value{int64(1), []byte("Go")}, + []driver.Value{int64(2), nil}, + ) + + records, err := Select().From(Book).Fetch(context.Background(), db) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if len(records) != 2 { + t.Fatalf("records = %d, want 2", len(records)) + } + + if !reflect.DeepEqual(records[0].Columns(), []string{"id", "title"}) { + t.Errorf("Columns = %v", records[0].Columns()) + } + title, err := Value[string](records[0], "title") + if err != nil || title != "Go" { + t.Errorf("Value[string] over []byte = %q, %v", title, err) + } + if records[1].GetByName("title") != nil { + t.Errorf("NULL column should be nil, got %#v", records[1].GetByName("title")) + } + nullTitle, err := Value[string](records[1], "title") + if err != nil || nullTitle != "" { + t.Errorf("Value[string] over NULL = %q, %v; want zero value", nullTitle, err) + } +} + +// TestSelectDynamicNullableProjection confirms that a pointer element type +// carries SQL NULL as a nil pointer, while a non-nullable element type +// surfaces the scan error exactly as the typed constructors do. +func TestSelectDynamicNullableProjection(t *testing.T) { + resetFake() + db := openFakeDB() + defer db.Close() + + nullableTitle := NewField[*string](Book.TableImpl, "title") + queueRows([]string{"title"}, []driver.Value{nil}) + records, err := Select(nullableTitle).From(Book).Fetch(context.Background(), db) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if got, ok := records[0].Get(0).(*string); !ok || got != nil { + t.Errorf("Get(0) = %#v, want nil *string", records[0].Get(0)) + } + + queueRows([]string{"title"}, []driver.Value{nil}) + _, err = Select(Book.Title).From(Book).Fetch(context.Background(), db) + if err == nil || !strings.Contains(err.Error(), "Scan") { + t.Errorf("scanning NULL into a non-nullable element should surface the scan error, got %v", err) + } +} + +// TestSelectDynamicSetOperations confirms that dynamic queries compose through +// set operations over the same Record row shape. +func TestSelectDynamicSetOperations(t *testing.T) { + checkSQL(t, + Select(Book.Title).From(Book).Union(Select(Author.Name.As("title")).From(Author)), + `SELECT "book"."title" FROM "book" UNION SELECT "author"."name" AS "title" FROM "author"`, + nil, + ) +} + +// TestSelectDynamicFetchOne confirms the single-row terminal operations reuse +// the dynamic scan path. +func TestSelectDynamicFetchOne(t *testing.T) { + resetFake() + db := openFakeDB() + defer db.Close() + + queueRows([]string{"total"}, []driver.Value{int64(3)}) + record, err := Select(CountStar().As("total")).From(Book).FetchOne(context.Background(), db) + if err != nil { + t.Fatalf("FetchOne: %v", err) + } + total, err := Value[int64](record, "total") + if err != nil || total != 3 { + t.Errorf("total = %d, %v; want 3", total, err) + } + + queueRows([]string{"total"}, []driver.Value{int64(1)}, []driver.Value{int64(2)}) + if _, err := Select(CountStar().As("total")).From(Book).FetchOne(context.Background(), db); !errors.Is(err, ErrTooManyRows) { + t.Errorf("FetchOne over two rows = %v, want ErrTooManyRows", err) + } +}