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)
+ }
+}