From 30cccbf561ad1fae8a83b3fe3899439bdfe72329 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 20:51:16 +0100 Subject: [PATCH 1/6] feat(typed-sql): add the typed SQL compiler core and dialect interface Pure compileTypedSQL(q, dialect, facts) for statically typed SQL engines, with an unexported typedDialect interface, catalog facts and a final placeholder numbering pass that skips quoted identifiers. Every value is a bound argument and every identifier is validated and quoted, so the SQL text never depends on a value. Unsupported nodes return errors matching dal.ErrNotSupported. Not wired into the readers yet (SQL-04). Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 263 +++++++++++ typed_dialect_fake_test.go | 253 ++++++++++ typed_dialect_test.go | 243 ++++++++++ typed_placeholders.go | 59 +++ typed_placeholders_test.go | 60 +++ typed_sql.go | 942 +++++++++++++++++++++++++++++++++++++ typed_sql_errors_test.go | 341 ++++++++++++++ typed_sql_property_test.go | 164 +++++++ typed_sql_test.go | 502 ++++++++++++++++++++ 9 files changed, 2827 insertions(+) create mode 100644 typed_dialect.go create mode 100644 typed_dialect_fake_test.go create mode 100644 typed_dialect_test.go create mode 100644 typed_placeholders.go create mode 100644 typed_placeholders_test.go create mode 100644 typed_sql.go create mode 100644 typed_sql_errors_test.go create mode 100644 typed_sql_property_test.go create mode 100644 typed_sql_test.go diff --git a/typed_dialect.go b/typed_dialect.go new file mode 100644 index 0000000..252aa12 --- /dev/null +++ b/typed_dialect.go @@ -0,0 +1,263 @@ +package dalgo2sql + +import ( + "context" + "database/sql/driver" + "fmt" + "reflect" + "strings" + "time" + "unicode/utf8" + + "github.com/dal-go/dalgo/dal" +) + +// typedDialect is the spelling guide for one statically typed SQL engine +// (PostgreSQL first; MySQL, SQL Server and Oracle later). compileTypedSQL owns +// the walk over the query; the dialect owns every engine-specific word. +// +// The contract that keeps the compiler safe: +// +// - Nothing a dialect returns may depend on a value. Values travel only as +// bound arguments, so the SQL text is the same whatever the caller's +// constants are. +// - Every placeholder a dialect writes is the neutral marker "?". The +// compiler numbers the markers once, at the end, with placeholderStyle. +// - Fragments that stand for an expression (divide, aggregateResult) must be +// self-delimiting, that is wrapped in parentheses or a function call, so +// the compiler can place them next to any operator. +// +// The interface stays unexported until three dialects exist. +type typedDialect interface { + // placeholderStyle tells the final numbering pass how to spell the nth + // placeholder and which byte delimits a quoted identifier. + placeholderStyle() typedPlaceholderStyle + + // quoteIdent validates one identifier segment (see checkTypedIdentifier: + // non-empty, no NUL, valid UTF-8, within the engine's length limit) and + // returns it quoted. It is the only way an identifier reaches SQL text. + quoteIdent(name string) (string, error) + + // bind returns the SQL that stands for one constant and the argument to + // send beside the text. The SQL contains exactly one marker, for example + // "?::bigint" for a whole number and "?" for a string whose type the server + // should infer. The value has already passed typedKindOf. + bind(value any) (marker string, arg any, err error) + + // limitOffset returns the row-limiting clause (without a leading space) and + // its arguments, or "" when limit and offset are both zero. Both numbers are + // non-negative; a zero limit means unlimited. + limitOffset(limit, offset int) (clause string, args []any) + + // orderItem renders one ORDER BY item. DALgo sorts NULL first ascending and + // last descending; notNull says the expression can never be NULL, so a + // dialect may drop its NULLS clause and let an index serve the order. + orderItem(expression string, descending, notNull bool) string + + // divide renders left / right with the engine's numeric semantics: DALgo + // divides as floating point and a zero divisor yields NULL. + divide(left, right string) string + + // aggregateResult wraps one rendered aggregate (function is dal.SUM, + // dal.COUNT, ...) so its result type matches DALgo's, for example a cast of + // SUM and AVG to double precision. + aggregateResult(function, aggregate string) string + + // emptyIn renders IN () (or NOT IN () when negated) over an empty list, + // which SQL has no syntax for: false, or true when negated, even for NULL. + emptyIn(negated bool) string + + // capabilities is what the engine runs natively. The compiler refuses any + // aggregation these flags do not cover. + capabilities() dal.QueryCapabilities + + // catalogFacts reads, with one catalog lookup, the facts compileTypedSQL + // needs about the sources of a query. The reader calls it before compiling. + catalogFacts(ctx context.Context, execute executeQueryFunc, sources []typedSourceName) (typedCatalogFacts, error) + + // window is reserved for window functions. DTQL has none today and the + // compiler never calls it; the seam exists so a dialect can add them later + // without changing this interface. + window(function string, args, partitionBy, orderBy []string) (string, error) +} + +// typedSourceName names a table as a query spells it. +type typedSourceName struct { + Schema string + Name string +} + +// typedTypeCategory groups catalog types by whether two columns can be +// compared. Number covers every integer, float and decimal type. +type typedTypeCategory int + +const ( + typedTypeUnknown typedTypeCategory = iota + typedTypeText + typedTypeNumber + typedTypeBoolean + typedTypeTime + typedTypeBinary + // typedTypeOther is for types such as uuid, json or an enum: two of them + // compare only when they are the same type. + typedTypeOther +) + +// typedColumnFact is what the catalog says about one column. +type typedColumnFact struct { + Name string + // DataType is the engine's own type name, used to accept a join between two + // columns of the same type that have no common category. + DataType string + Category typedTypeCategory + NotNull bool + // NonDeterministicCollation marks columns whose equality is not byte + // equality (citext, ICU nondeterministic collations). The compiler carries + // it for dialect helpers; it does not act on it. + NonDeterministicCollation bool +} + +// typedSourceFacts holds a source's columns in table order. +type typedSourceFacts struct { + Columns []typedColumnFact +} + +// typedCatalogFacts is the compiler's whole knowledge of the database. The +// zero value knows nothing, and the compiler then emits correct but less +// optimised SQL (no wildcard expansion, no NOT NULL shortcuts, no join key +// type check). +type typedCatalogFacts struct { + // Fold maps a name to the key it is stored under, for engines or modes + // that fold identifier case. Nil means names are matched exactly. + Fold func(string) string + Sources map[typedSourceName]typedSourceFacts +} + +func (f typedCatalogFacts) fold(name string) string { + if f.Fold == nil { + return name + } + return f.Fold(name) +} + +func (f typedCatalogFacts) source(name typedSourceName) (typedSourceFacts, bool) { + source, ok := f.Sources[typedSourceName{Schema: f.fold(name.Schema), Name: f.fold(name.Name)}] + return source, ok +} + +func (f typedCatalogFacts) column(name typedSourceName, column string) (typedColumnFact, bool) { + source, ok := f.source(name) + if !ok { + return typedColumnFact{}, false + } + key := f.fold(column) + for _, candidate := range source.Columns { + if f.fold(candidate.Name) == key { + return candidate, true + } + } + return typedColumnFact{}, false +} + +// typedJoinKeysComparable reports whether a join may equate two columns on a +// statically typed engine: they share a scalar category, or they are the same +// named type. A column of unknown type is comparable only to the same type. +func typedJoinKeysComparable(a, b typedColumnFact) bool { + if a.Category == b.Category && a.Category != typedTypeUnknown && a.Category != typedTypeOther { + return true + } + return a.DataType != "" && a.DataType == b.DataType +} + +// checkTypedIdentifier is the validation every dialect's quoteIdent starts +// with. It never echoes the identifier, which may be hostile or very long. +func checkTypedIdentifier(name string, maxBytes int) error { + switch { + case name == "": + return fmt.Errorf("identifier is empty") + case strings.IndexByte(name, 0) >= 0: + return fmt.Errorf("identifier contains a NUL byte") + case !utf8.ValidString(name): + return fmt.Errorf("identifier is not valid UTF-8") + case len(name) > maxBytes: + return fmt.Errorf("identifier is %d bytes, over the engine limit of %d", len(name), maxBytes) + } + return nil +} + +// quoteTypedIdentifier wraps name in quote and doubles any quote inside it. +// The name must already have passed checkTypedIdentifier. +func quoteTypedIdentifier(name string, quote byte) string { + q := string(quote) + return q + strings.ReplaceAll(name, q, q+q) + q +} + +// isQuotedTypedIdentifier reports whether text is exactly one quoted +// identifier: wrapped in quote, never empty, with every inner quote doubled and +// no NUL byte. +func isQuotedTypedIdentifier(text string, quote byte) bool { + if len(text) < 3 || text[0] != quote || text[len(text)-1] != quote { + return false + } + inner := text[1 : len(text)-1] + for i := 0; i < len(inner); i++ { + switch inner[i] { + case 0: + return false + case quote: + if i+1 >= len(inner) || inner[i+1] != quote { + return false + } + i++ + } + } + return true +} + +// typedValueKind classifies the Go type of a constant so a dialect can type +// its placeholder by the type, never by the value. +type typedValueKind int + +const ( + typedValueNull typedValueKind = iota + typedValueValuer + typedValueTime + typedValueBool + typedValueInteger + typedValueUnsigned + typedValueFloat + typedValueText + typedValueBytes +) + +// typedKindOf accepts the constant types DALgo can hold and refuses the rest +// with an error matching dal.ErrNotSupported. +func typedKindOf(value any) (typedValueKind, error) { + if value == nil { + return typedValueNull, nil + } + if _, ok := value.(driver.Valuer); ok { + return typedValueValuer, nil + } + if _, ok := value.(time.Time); ok { + return typedValueTime, nil + } + valueType := reflect.TypeOf(value) + switch valueType.Kind() { + case reflect.Bool: + return typedValueBool, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return typedValueInteger, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return typedValueUnsigned, nil + case reflect.Float32, reflect.Float64: + return typedValueFloat, nil + case reflect.String: + return typedValueText, nil + case reflect.Slice: + if valueType.Elem().Kind() == reflect.Uint8 { + return typedValueBytes, nil + } + } + return typedValueNull, typedUnsupported("SQL value type %T", value) +} diff --git a/typed_dialect_fake_test.go b/typed_dialect_fake_test.go new file mode 100644 index 0000000..4e8718e --- /dev/null +++ b/typed_dialect_fake_test.go @@ -0,0 +1,253 @@ +package dalgo2sql + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/dal-go/dalgo/dal" +) + +// fakeTypedDialect is a PostgreSQL-shaped dialect used only to prove the typed +// compiler. It needs no database: golden tests compare SQL text and arguments. +type fakeTypedDialect struct { + style typedPlaceholderStyle + caps dal.QueryCapabilities + identLimit int + // quoteOverride and bindMarkers let a test make the dialect misbehave. + quoteOverride func(string) (string, error) + bindMarkers string +} + +func newFakeTypedDialect() *fakeTypedDialect { + return &fakeTypedDialect{ + style: typedPlaceholderStyle{Prefix: "$", IdentQuote: '"'}, + caps: fullFakeTypedCapabilities(), + identLimit: 63, + } +} + +func fullFakeTypedCapabilities() dal.QueryCapabilities { + return dal.QueryCapabilities{ + GroupBy: true, + Having: true, + OrderBy: true, + Aggregate: dal.AggregateCapabilities{ + Count: true, CountDistinct: true, + Sum: true, SumDistinct: true, + Avg: true, AvgDistinct: true, + Min: true, Max: true, + OrderBy: true, + }, + } +} + +func (d *fakeTypedDialect) placeholderStyle() typedPlaceholderStyle { return d.style } + +func (d *fakeTypedDialect) quoteIdent(name string) (string, error) { + if d.quoteOverride != nil { + return d.quoteOverride(name) + } + if err := checkTypedIdentifier(name, d.identLimit); err != nil { + return "", err + } + return quoteTypedIdentifier(name, '"'), nil +} + +var errFakeTypedBind = errors.New("fake dialect refuses this value") + +// fakeTypedFailingValuer is accepted by the compiler (it is a driver.Valuer) and +// refused by the fake dialect, which exercises the dialect error path. +type fakeTypedFailingValuer struct{} + +func (fakeTypedFailingValuer) Value() (driver.Value, error) { return nil, nil } + +func (d *fakeTypedDialect) bind(value any) (string, any, error) { + kind, err := typedKindOf(value) + if err != nil { + return "", nil, err + } + if d.bindMarkers != "" { + return d.bindMarkers, value, nil + } + switch kind { + case typedValueValuer: + return "", nil, errFakeTypedBind + case typedValueTime: + return "?::timestamptz", value, nil + case typedValueBool: + return "?::boolean", value, nil + case typedValueInteger, typedValueUnsigned: + return "?::bigint", value, nil + case typedValueFloat: + return "?::numeric", value, nil + case typedValueBytes: + return "?::bytea", value, nil + default: // null and text stay untyped so the server infers the type + return "?", value, nil + } +} + +func (d *fakeTypedDialect) limitOffset(limit, offset int) (string, []any) { + switch { + case limit > 0 && offset > 0: + return "LIMIT ? OFFSET ?", []any{limit, offset} + case limit > 0: + return "LIMIT ?", []any{limit} + case offset > 0: + return "OFFSET ?", []any{offset} + } + return "", nil +} + +func (d *fakeTypedDialect) orderItem(expression string, descending, notNull bool) string { + switch { + case notNull && descending: + return expression + " DESC" + case notNull: + return expression + " ASC" + case descending: + return expression + " DESC NULLS LAST" + } + return expression + " ASC NULLS FIRST" +} + +func (d *fakeTypedDialect) divide(left, right string) string { + return "(CAST(" + left + " AS double precision) / NULLIF(CAST(" + right + " AS double precision), 0))" +} + +func (d *fakeTypedDialect) aggregateResult(function, aggregate string) string { + if function == dal.SUM || function == dal.AVERAGE { + return "CAST(" + aggregate + " AS double precision)" + } + return aggregate +} + +func (d *fakeTypedDialect) emptyIn(negated bool) string { + if negated { + return "TRUE" + } + return "FALSE" +} + +func (d *fakeTypedDialect) capabilities() dal.QueryCapabilities { return d.caps } + +func (d *fakeTypedDialect) catalogFacts(context.Context, executeQueryFunc, []typedSourceName) (typedCatalogFacts, error) { + return typedCatalogFacts{}, nil +} + +func (d *fakeTypedDialect) window(string, []string, []string, []string) (string, error) { + return "", fmt.Errorf("window functions are reserved: %w", dal.ErrNotSupported) +} + +var _ typedDialect = (*fakeTypedDialect)(nil) + +// typedGolden is one clause-level golden case. +type typedGolden struct { + name string + query dal.StructuredQuery + facts typedCatalogFacts + dialect *fakeTypedDialect + wantSQL string + wantArgs []any +} + +func (g typedGolden) run(t *testing.T) { + t.Helper() + dialect := g.dialect + if dialect == nil { + dialect = newFakeTypedDialect() + } + gotSQL, gotArgs, err := compileTypedSQL(g.query, dialect, g.facts) + if err != nil { + t.Fatalf("compileTypedSQL() error = %v", err) + } + if gotSQL != g.wantSQL { + t.Fatalf("SQL mismatch\n got: %s\nwant: %s", gotSQL, g.wantSQL) + } + if len(gotArgs) == 0 && len(g.wantArgs) == 0 { + return + } + if !reflect.DeepEqual(gotArgs, g.wantArgs) { + t.Fatalf("args mismatch\n got: %#v\nwant: %#v", gotArgs, g.wantArgs) + } +} + +func runTypedGoldens(t *testing.T, cases []typedGolden) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, tc.run) + } +} + +// expectTypedUnsupported compiles q and requires an error matching +// dal.ErrNotSupported whose text contains fragment. +func expectTypedUnsupported(t *testing.T, q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts, fragment string) { + t.Helper() + if dialect == nil { + dialect = newFakeTypedDialect() + } + text, args, err := compileTypedSQL(q, dialect, facts) + if err == nil { + t.Fatalf("compileTypedSQL() = %q, %v; want an unsupported error", text, args) + } + if !errors.Is(err, dal.ErrNotSupported) { + t.Fatalf("error %q does not match dal.ErrNotSupported", err) + } + if text != "" || args != nil { + t.Fatalf("a refused query must return no SQL, got %q %v", text, args) + } + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error %q does not mention %q", err, fragment) + } +} + +// expectTypedInvalid compiles q and requires a non-nil error that is NOT a +// capability refusal: the query itself is malformed. +func expectTypedInvalid(t *testing.T, q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts, fragment string) { + t.Helper() + if dialect == nil { + dialect = newFakeTypedDialect() + } + text, args, err := compileTypedSQL(q, dialect, facts) + if err == nil { + t.Fatalf("compileTypedSQL() = %q, %v; want an error", text, args) + } + if errors.Is(err, dal.ErrNotSupported) { + t.Fatalf("error %q must not claim ErrNotSupported", err) + } + if text != "" || args != nil { + t.Fatalf("a refused query must return no SQL, got %q %v", text, args) + } + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error %q does not mention %q", err, fragment) + } +} + +func typedTestField(name string) dal.FieldRef { return dal.NewFieldRef("", name) } + +func typedTestQualified(source, name string) dal.FieldRef { return dal.NewFieldRef(source, name) } + +// typedTestConst builds a Constant directly: dal.NewConstant panics on types the +// compiler is expected to refuse. +func typedTestConst(value any) dal.Constant { return dal.Constant{Value: value} } + +func typedTestEq(left dal.Expression, right any) dal.Comparison { + return dal.NewComparison(left, dal.Equal, typedTestConst(right)) +} + +func typedTestFrom(name, alias string) dal.FromSource { + return dal.From(dal.NewRootCollectionRef(name, alias)) +} + +func typedTestColumn(expression dal.Expression, alias string) dal.Column { + return dal.Column{Expression: expression, Alias: alias} +} + +func typedTestJoinOn(leftSource, leftField, rightSource, rightField string) dal.Condition { + return dal.NewComparison(typedTestQualified(leftSource, leftField), dal.Equal, typedTestQualified(rightSource, rightField)) +} diff --git a/typed_dialect_test.go b/typed_dialect_test.go new file mode 100644 index 0000000..f6293bb --- /dev/null +++ b/typed_dialect_test.go @@ -0,0 +1,243 @@ +package dalgo2sql + +import ( + "database/sql" + "strings" + "testing" + "time" +) + +func TestCheckTypedIdentifier(t *testing.T) { + accepted := map[string]string{ + "plain": "Album", + "mixed case and space": "Album Title", + "punctuation": `we?ird"name`, + "exactly the limit": strings.Repeat("n", 63), + "multi-byte": strings.Repeat("é", 31), // 62 bytes + } + for name, identifier := range accepted { + t.Run("accepts "+name, func(t *testing.T) { + if err := checkTypedIdentifier(identifier, 63); err != nil { + t.Fatalf("checkTypedIdentifier(%q) = %v", identifier, err) + } + }) + } + refused := map[string]struct{ identifier, fragment string }{ + "empty": {"", "empty"}, + "NUL": {"secret\x00name", "NUL"}, + "over the limit": {strings.Repeat("n", 64), "limit"}, + "multi-byte over the limit": {strings.Repeat("é", 32), "limit"}, + "invalid UTF-8": {"abc\xff", "UTF-8"}, + "truncated UTF-8 at the edge": {"abc\xc3", "UTF-8"}, + } + for name, tc := range refused { + t.Run("refuses "+name, func(t *testing.T) { + err := checkTypedIdentifier(tc.identifier, 63) + if err == nil || !strings.Contains(err.Error(), tc.fragment) { + t.Fatalf("checkTypedIdentifier(%q) = %v, want an error mentioning %q", tc.identifier, err, tc.fragment) + } + if tc.identifier != "" && strings.Contains(err.Error(), tc.identifier) { + t.Fatalf("error %q must not echo the identifier", err) + } + }) + } +} + +func TestQuoteTypedIdentifier(t *testing.T) { + cases := []struct { + name, identifier string + quote byte + want string + }{ + {"plain", "Album", '"', `"Album"`}, + {"embedded double quote is doubled", `a"b`, '"', `"a""b"`}, + {"only quotes", `""`, '"', `""""""`}, + {"placeholder character is untouched", `a?b`, '"', `"a?b"`}, + {"backtick style", "a`b", '`', "`a``b`"}, + {"other quote character is untouched", `a"b`, '`', "`a\"b`"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := quoteTypedIdentifier(tc.identifier, tc.quote); got != tc.want { + t.Fatalf("quoteTypedIdentifier(%q) = %s, want %s", tc.identifier, got, tc.want) + } + }) + } +} + +type typedTestNamedString string +type typedTestNamedBytes []byte + +func TestTypedKindOf(t *testing.T) { + var nilPointer *int + accepted := []struct { + name string + value any + want typedValueKind + }{ + {"nil", nil, typedValueNull}, + {"driver.Valuer", sql.NullString{String: "x", Valid: true}, typedValueValuer}, + {"time", time.Now(), typedValueTime}, + {"bool", true, typedValueBool}, + {"int", 1, typedValueInteger}, + {"int8", int8(1), typedValueInteger}, + {"int16", int16(1), typedValueInteger}, + {"int32", int32(1), typedValueInteger}, + {"int64", int64(1), typedValueInteger}, + {"uint", uint(1), typedValueUnsigned}, + {"uint8", uint8(1), typedValueUnsigned}, + {"uint16", uint16(1), typedValueUnsigned}, + {"uint32", uint32(1), typedValueUnsigned}, + {"uint64", uint64(1), typedValueUnsigned}, + {"float32", float32(1), typedValueFloat}, + {"float64", 1.5, typedValueFloat}, + {"string", "x", typedValueText}, + {"named string", typedTestNamedString("x"), typedValueText}, + {"bytes", []byte("x"), typedValueBytes}, + {"named bytes", typedTestNamedBytes("x"), typedValueBytes}, + } + for _, tc := range accepted { + t.Run("accepts "+tc.name, func(t *testing.T) { + got, err := typedKindOf(tc.value) + if err != nil || got != tc.want { + t.Fatalf("typedKindOf(%T) = %v, %v; want %v", tc.value, got, err, tc.want) + } + }) + } + refused := []struct { + name string + value any + }{ + {"struct", struct{}{}}, + {"map", map[string]int{}}, + {"int slice", []int{1}}, + {"pointer", nilPointer}, + {"channel", make(chan int)}, + {"function", func() {}}, + } + for _, tc := range refused { + t.Run("refuses "+tc.name, func(t *testing.T) { + if _, err := typedKindOf(tc.value); err == nil { + t.Fatalf("typedKindOf(%T) must fail", tc.value) + } + }) + } +} + +func TestTypedCatalogFactsLookups(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Schema: "public", Name: "album"}: {Columns: []typedColumnFact{{Name: "albumid", NotNull: true}, {Name: "title"}}}, + {Name: "artist"}: {Columns: []typedColumnFact{{Name: "name", NonDeterministicCollation: true}}}, + }} + t.Run("exact spelling", func(t *testing.T) { + source, ok := facts.source(typedSourceName{Schema: "public", Name: "album"}) + if !ok || len(source.Columns) != 2 { + t.Fatalf("source() = %v, %v", source, ok) + } + if _, ok := facts.source(typedSourceName{Schema: "public", Name: "Album"}); ok { + t.Fatal("exact lookup must be case-sensitive") + } + if column, ok := facts.column(typedSourceName{Name: "artist"}, "name"); !ok || column.Name != "name" { + t.Fatalf("column() = %v, %v", column, ok) + } + if _, ok := facts.column(typedSourceName{Name: "artist"}, "Name"); ok { + t.Fatal("exact lookup must be case-sensitive") + } + if _, ok := facts.column(typedSourceName{Name: "artist"}, "missing"); ok { + t.Fatal("a missing column must not be found") + } + if _, ok := facts.column(typedSourceName{Name: "missing"}, "name"); ok { + t.Fatal("a missing source must not be found") + } + }) + t.Run("folded spelling", func(t *testing.T) { + folded := facts + folded.Fold = strings.ToLower + if _, ok := folded.source(typedSourceName{Schema: "PUBLIC", Name: "Album"}); !ok { + t.Fatal("a folding lookup must find the source") + } + if column, ok := folded.column(typedSourceName{Schema: "Public", Name: "ALBUM"}, "AlbumId"); !ok || !column.NotNull { + t.Fatalf("column() = %v, %v", column, ok) + } + }) + t.Run("zero value has no facts", func(t *testing.T) { + var none typedCatalogFacts + if _, ok := none.source(typedSourceName{Name: "album"}); ok { + t.Fatal("zero facts must know nothing") + } + if _, ok := none.column(typedSourceName{Name: "album"}, "title"); ok { + t.Fatal("zero facts must know nothing") + } + }) +} + +func TestTypedJoinKeysComparable(t *testing.T) { + col := func(dataType string, category typedTypeCategory) typedColumnFact { + return typedColumnFact{DataType: dataType, Category: category} + } + cases := []struct { + name string + a, b typedColumnFact + want bool + }{ + {"same scalar category", col("int4", typedTypeNumber), col("numeric", typedTypeNumber), true}, + {"text and text", col("text", typedTypeText), col("varchar", typedTypeText), true}, + {"different categories", col("int4", typedTypeNumber), col("text", typedTypeText), false}, + {"same other type", col("uuid", typedTypeOther), col("uuid", typedTypeOther), true}, + {"different other types", col("uuid", typedTypeOther), col("json", typedTypeOther), false}, + {"same unknown type name", col("mystery", typedTypeUnknown), col("mystery", typedTypeUnknown), true}, + {"unknown without names", col("", typedTypeUnknown), col("", typedTypeUnknown), false}, + {"unknown against known", col("mystery", typedTypeUnknown), col("text", typedTypeText), false}, + {"same name different category", col("x", typedTypeText), col("x", typedTypeNumber), true}, + {"boolean and boolean", col("bool", typedTypeBoolean), col("boolean", typedTypeBoolean), true}, + {"time and time", col("timestamptz", typedTypeTime), col("date", typedTypeTime), true}, + {"binary and binary", col("bytea", typedTypeBinary), col("blob", typedTypeBinary), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := typedJoinKeysComparable(tc.a, tc.b); got != tc.want { + t.Fatalf("typedJoinKeysComparable() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestIsQuotedTypedIdentifier(t *testing.T) { + cases := []struct { + name string + text string + quote byte + want bool + }{ + {"plain", `"Album"`, '"', true}, + {"doubled inner quotes", `"a""b"`, '"', true}, + {"only a doubled quote", `""""`, '"', true}, + {"placeholder character inside", `"a?b"`, '"', true}, + {"backtick", "`a``b`", '`', true}, + {"empty identifier", `""`, '"', false}, + {"empty text", ``, '"', false}, + {"lone quote", `"`, '"', false}, + {"not wrapped", `Album`, '"', false}, + {"no closing quote", `"Album`, '"', false}, + {"no opening quote", `Album"`, '"', false}, + {"undoubled inner quote", `"a"b"`, '"', false}, + {"odd run of inner quotes", `"a"""b"`, '"', false}, + {"escaped quote swallows the closing quote", `"a""`, '"', false}, + {"escaped quote at the end is fine", `"a"""`, '"', true}, + {"NUL inside", "\"a\x00b\"", '"', false}, + {"wrong quote byte", `"a"`, '`', false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isQuotedTypedIdentifier(tc.text, tc.quote); got != tc.want { + t.Fatalf("isQuotedTypedIdentifier(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } + // Whatever the name, quoteTypedIdentifier produces a well-formed identifier. + for _, name := range []string{`a`, `"`, `""`, `a"b"c`, `?`, "multi\nline", `é`} { + if quoted := quoteTypedIdentifier(name, '"'); !isQuotedTypedIdentifier(quoted, '"') { + t.Fatalf("quoteTypedIdentifier(%q) = %s is not well formed", name, quoted) + } + } +} diff --git a/typed_placeholders.go b/typed_placeholders.go new file mode 100644 index 0000000..84ef08f --- /dev/null +++ b/typed_placeholders.go @@ -0,0 +1,59 @@ +package dalgo2sql + +import ( + "fmt" + "strconv" + "strings" +) + +// typedMarker is the neutral placeholder every fragment of a typed statement +// uses while it is being assembled. Fragments are built independently and in an +// order that differs from the text (a grouped expression may be replaced by its +// ordinal after it was compiled), so numbering them while compiling would be +// fragile. numberTypedPlaceholders numbers them once, over the final text. +const typedMarker = '?' + +// typedPlaceholderStyle describes how a dialect spells placeholders. +type typedPlaceholderStyle struct { + // Prefix, when not empty, makes the nth marker "n": "$" gives $1, + // $2, ... Empty keeps the marker as "?" (SQLite and MySQL style). + Prefix string + // IdentQuote is the byte that opens and closes a quoted identifier. An + // identifier may legally contain the marker, so nothing between two quotes + // is numbered. A doubled quote inside an identifier toggles twice and + // leaves the scan in the right state. + IdentQuote byte +} + +// numberTypedPlaceholders rewrites the markers of text into the dialect's +// style, skipping quoted identifiers, and checks that the text carries exactly +// one marker per argument: a mismatch means a fragment lost or invented a +// parameter, and executing it could bind a value to the wrong position. +func numberTypedPlaceholders(text string, style typedPlaceholderStyle, arguments int) (string, error) { + var out strings.Builder + out.Grow(len(text) + 2*arguments) + inIdentifier := false + markers := 0 + for i := 0; i < len(text); i++ { + b := text[i] + switch { + case b == style.IdentQuote: + inIdentifier = !inIdentifier + case b == typedMarker && !inIdentifier: + markers++ + if style.Prefix != "" { + out.WriteString(style.Prefix) + out.WriteString(strconv.Itoa(markers)) + continue + } + } + out.WriteByte(b) + } + if inIdentifier { + return "", fmt.Errorf("typed SQL: unterminated quoted identifier") + } + if markers != arguments { + return "", fmt.Errorf("typed SQL: %d placeholders for %d arguments", markers, arguments) + } + return out.String(), nil +} diff --git a/typed_placeholders_test.go b/typed_placeholders_test.go new file mode 100644 index 0000000..b64c3ec --- /dev/null +++ b/typed_placeholders_test.go @@ -0,0 +1,60 @@ +package dalgo2sql + +import ( + "strings" + "testing" +) + +func TestNumberTypedPlaceholders(t *testing.T) { + dollar := typedPlaceholderStyle{Prefix: "$", IdentQuote: '"'} + question := typedPlaceholderStyle{IdentQuote: '"'} + cases := []struct { + name string + text string + style typedPlaceholderStyle + args int + want string + }{ + {"no markers", `SELECT * FROM "t"`, dollar, 0, `SELECT * FROM "t"`}, + {"numbered in order", `a = ? AND b = ? AND c = ?`, dollar, 3, `a = $1 AND b = $2 AND c = $3`}, + {"typed markers keep their suffix", `a = ?::bigint, b = ?`, dollar, 2, `a = $1::bigint, b = $2`}, + {"markers inside a quoted identifier are skipped", `"a?b" = ? AND "?" = ?`, dollar, 2, `"a?b" = $1 AND "?" = $2`}, + {"a doubled quote stays inside the identifier", `"a""?""b" = ?`, dollar, 1, `"a""?""b" = $1`}, + {"identifier made only of quotes", `"""" = ? AND ? = 1`, dollar, 2, `"""" = $1 AND $2 = 1`}, + {"two digit numbers", strings.Repeat("?,", 12), dollar, 12, "$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,"}, + {"question style keeps the text", `"a?b" = ? AND c = ?`, question, 2, `"a?b" = ? AND c = ?`}, + {"other prefix", `a = ?`, typedPlaceholderStyle{Prefix: "@p", IdentQuote: '"'}, 1, `a = @p1`}, + {"backtick identifiers", "`a?` = ?", typedPlaceholderStyle{Prefix: "$", IdentQuote: '`'}, 1, "`a?` = $1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := numberTypedPlaceholders(tc.text, tc.style, tc.args) + if err != nil || got != tc.want { + t.Fatalf("numberTypedPlaceholders() = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestNumberTypedPlaceholdersRejectsInconsistentText(t *testing.T) { + dollar := typedPlaceholderStyle{Prefix: "$", IdentQuote: '"'} + cases := []struct { + name string + text string + args int + fragment string + }{ + {"more markers than arguments", `a = ? AND b = ?`, 1, "2 placeholders for 1 arguments"}, + {"fewer markers than arguments", `a = ?`, 2, "1 placeholders for 2 arguments"}, + {"an identifier that never closes", `"a = ?`, 1, "quoted identifier"}, + {"an identifier that never closes hides its marker", `x = ? AND "a = ?`, 2, "quoted identifier"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := numberTypedPlaceholders(tc.text, dollar, tc.args) + if err == nil || !strings.Contains(err.Error(), tc.fragment) || got != "" { + t.Fatalf("numberTypedPlaceholders() = %q, %v; want an error mentioning %q", got, err, tc.fragment) + } + }) + } +} diff --git a/typed_sql.go b/typed_sql.go new file mode 100644 index 0000000..92c5f36 --- /dev/null +++ b/typed_sql.go @@ -0,0 +1,942 @@ +package dalgo2sql + +import ( + "errors" + "fmt" + "maps" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/dal-go/dalgo/dal" +) + +// typedMaxArguments bounds the values one statement may bind. PostgreSQL's wire +// protocol counts parameters in 16 bits; no other engine allows more. +const typedMaxArguments = 65535 + +// typedUnsupported builds an error matching dal.ErrNotSupported. Callers use it +// for every query shape the typed compiler declines, so a caller can tell "this +// engine cannot run it in one statement" from "the query is malformed". The +// message never includes caller values. +func typedUnsupported(format string, args ...any) error { + return fmt.Errorf("%w: %s", dal.ErrNotSupported, fmt.Sprintf(format, args...)) +} + +// compileTypedSQL renders one structured query as a single SELECT for a +// statically typed SQL engine described by dialect. It is a pure function: it +// reads no database, and facts carries everything it knows about the catalog. +// +// Every value is a bound argument and every identifier goes through +// dialect.quoteIdent, so the returned text depends on the shape of the query +// and never on the values in it. Anything the engine cannot run faithfully +// returns an error matching dal.ErrNotSupported rather than an approximation; +// DALgo's generic engine remains the place for those queries. +func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts) (string, []any, error) { + if dialect == nil { + return "", nil, errors.New("typed SQL compiler requires a dialect") + } + if q == nil || q.From() == nil || q.From().Base() == nil { + return "", nil, errors.New("typed SQL query requires a source") + } + if q.StartFrom() != "" || q.StartAfter() != "" { + return "", nil, typedUnsupported("cursors (startFrom, startAfter)") + } + if dal.HasSubquery(q) { + return "", nil, typedUnsupported("subqueries run in the generic engine, not in one statement") + } + if q.Limit() < 0 || q.Offset() < 0 { + return "", nil, errors.New("limit and offset must be non-negative") + } + if err := validateTypedAggregation(q, dialect); err != nil { + return "", nil, err + } + if len(q.From().Joins()) != 0 { + if err := validateTypedJoins(q.From()); err != nil { + return "", nil, err + } + } + c := &typedCompiler{dialect: dialect, facts: facts} + text, args, err := c.query(q) + if err != nil { + return "", nil, err + } + text, err = numberTypedPlaceholders(text, dialect.placeholderStyle(), len(args)) + if err != nil { + return "", nil, err + } + return text, args, nil +} + +func validateTypedAggregation(q dal.StructuredQuery, dialect typedDialect) error { + if err := dal.ValidateAggregation(q); err != nil { + return fmt.Errorf("invalid aggregation: %w", err) + } + plan, err := dal.PlanAggregation(q, dialect.capabilities()) + if err != nil { + // The aggregation is valid, so the one refusal left is FIRST or LAST, + // which need an input order no SQL engine guarantees. + return fmt.Errorf("%w: %v", dal.ErrNotSupported, err) + } + if plan.Strategy != dal.AggregationNative { + return typedUnsupported("the dialect does not run this aggregation natively: %s", plan.Reason) + } + return nil +} + +// validateTypedJoins applies DALgo's structural join validation. A join type or +// operator the model reserves for later is a missing feature, not a malformed +// query, so those two categories also match dal.ErrNotSupported. +func validateTypedJoins(from dal.FromSource) error { + err := dal.ValidateJoinTree(from) + var joinErr *dal.JoinValidationError + if errors.As(err, &joinErr) && (joinErr.Category == "join_type" || joinErr.Category == "join_operator") { + return fmt.Errorf("%w: %w", dal.ErrNotSupported, err) + } + return err +} + +// typedCompiler carries the state of one compileTypedSQL call. +type typedCompiler struct { + dialect typedDialect + facts typedCatalogFacts + + // sources maps every source identity visible to the statement (the alias, + // else the table name) to the table it names. + sources map[string]typedSource + // nullable holds identities on the nullable side of an outer join: a column + // that is NOT NULL in the catalog can still be NULL there. + nullable map[string]bool + // base is the identity of the first FROM source. + base string + hasJoins bool + // noAggregates is set while compiling WHERE, where aggregates are illegal. + noAggregates bool +} + +// typedSource is one source visible to a statement. +type typedSource struct { + // quoted is the source's identity, quoted once when its FROM entry was + // rendered, ready to qualify a field. + quoted string + table typedSourceName +} + +type typedSelectItem struct { + // expression is nil for the bare "*" of a query with no columns. + expression dal.Expression + sql string + args []any +} + +// query assembles the statement. FROM is rendered first because it fixes which +// sources are visible, but it binds no value, so the arguments still follow the +// text order: select list, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT and OFFSET. +func (c *typedCompiler) query(q dal.StructuredQuery) (string, []any, error) { + relation, err := c.relation(q.From(), "from", nil) + if err != nil { + return "", nil, err + } + c.sources, c.nullable = relation.sources, relation.nullable + c.base = typedIdentity(q.From().Base()) + c.hasJoins = len(q.From().Joins()) != 0 + + columns := q.Columns() + if dal.HasAggregation(q) { + columns = dal.EffectiveAggregationColumns(q) + } + items, err := c.selectItems(q, columns) + if err != nil { + return "", nil, err + } + aliases := make(map[string]dal.Expression, len(columns)) + for _, column := range columns { + if column.Alias != "" { + aliases[column.Alias] = column.Expression + } + } + + var text strings.Builder + var args []any + text.WriteString("SELECT ") + for i, item := range items { + if i > 0 { + text.WriteString(", ") + } + text.WriteString(item.sql) + args = append(args, item.args...) + } + text.WriteString(" FROM ") + text.WriteString(relation.sql) + + // Arguments are collected in the order the clauses appear in the text. + for _, clause := range []func() (string, []any, error){ + func() (string, []any, error) { return c.where(q.Where()) }, + func() (string, []any, error) { return c.groupBy(q.GroupBy(), items) }, + func() (string, []any, error) { return c.having(q.Having(), aliases) }, + func() (string, []any, error) { return c.orderBy(q.OrderBy(), items, aliases) }, + func() (string, []any, error) { + clause, limitArgs := c.dialect.limitOffset(q.Limit(), q.Offset()) + if clause == "" { + return "", nil, nil + } + return " " + clause, limitArgs, nil + }, + } { + clauseText, clauseArgs, err := clause() + if err != nil { + return "", nil, err + } + text.WriteString(clauseText) + args = append(args, clauseArgs...) + } + return text.String(), args, nil +} + +// selectItems renders the SELECT list. Aggregate queries pass their effective +// columns (the group keys when none were named). +func (c *typedCompiler) selectItems(q dal.StructuredQuery, columns []dal.Column) ([]typedSelectItem, error) { + if len(columns) == 0 { + return []typedSelectItem{{sql: "*"}}, nil + } + wildcard, err := planWildcardProjection(q) + if err != nil { + return nil, typedUnsupported("%v", err) + } + var items []typedSelectItem + for i, column := range columns { + if column.Wildcard != nil { + expanded, err := c.expandWildcard(wildcard) + if err != nil { + return nil, fmt.Errorf("column %d: %w", i, err) + } + items = append(items, expanded...) + continue + } + item, err := c.selectItem(column) + if err != nil { + return nil, fmt.Errorf("column %d: %w", i, err) + } + items = append(items, item) + } + if len(items) == 0 { + return nil, typedUnsupported("the wildcard projection excludes every column") + } + return items, nil +} + +// expandWildcard replaces `*` with the source's columns from the catalog +// facts, minus the exclusions. No value is read to do it. +func (c *typedCompiler) expandWildcard(wildcard *wildcardProjectionPlan) ([]typedSelectItem, error) { + source, ok := c.facts.source(c.sources[c.base].table) + if !ok { + return nil, typedUnsupported("a wildcard projection needs catalog facts for its source") + } + items := make([]typedSelectItem, 0, len(source.Columns)) + for _, column := range source.Columns { + if wildcard.projection.Excludes(column.Name) { + continue + } + quoted, err := c.quote(column.Name) + if err != nil { + return nil, err + } + items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted}) + } + return items, nil +} + +// selectItem renders one column. A result column keeps the name DALgo's +// generic engine gives it: the alias, else the field name, else the +// expression's own text. That last name must not contain a value, so an +// unaliased expression that carries a constant is refused. +func (c *typedCompiler) selectItem(column dal.Column) (typedSelectItem, error) { + sql, args, err := c.expr(column.Expression) + if err != nil { + return typedSelectItem{}, err + } + item := typedSelectItem{expression: column.Expression, sql: sql, args: args} + name := column.Alias + if name == "" { + if _, isField := column.Expression.(dal.FieldRef); isField { + return item, nil + } + if len(args) != 0 { + return typedSelectItem{}, typedUnsupported("an expression carrying a constant needs an alias") + } + name = column.Expression.String() + } + quoted, err := c.quote(name) + if err != nil { + return typedSelectItem{}, err + } + item.sql += " AS " + quoted + return item, nil +} + +func (c *typedCompiler) where(condition dal.Condition) (string, []any, error) { + if condition == nil { + return "", nil, nil + } + c.noAggregates = true + defer func() { c.noAggregates = false }() + sql, args, err := c.condition(condition) + if err != nil { + return "", nil, fmt.Errorf("where: %w", err) + } + return " WHERE " + sql, args, nil +} + +func (c *typedCompiler) groupBy(groups []dal.Expression, items []typedSelectItem) (string, []any, error) { + if len(groups) == 0 { + return "", nil, nil + } + parts := make([]string, len(groups)) + var args []any + for i, group := range groups { + sql, values, err := c.byExpression(group, items) + if err != nil { + return "", nil, fmt.Errorf("groupBy %d: %w", i, err) + } + parts[i] = sql + args = append(args, values...) + } + return " GROUP BY " + strings.Join(parts, ", "), args, nil +} + +// having rewrites select aliases to their expressions, as PostgreSQL does not +// let HAVING see output names. A grouped expression that carries a constant +// cannot be referenced that way (see byExpression), and HAVING has no ordinal +// form, so it is refused. +func (c *typedCompiler) having(condition dal.Condition, aliases map[string]dal.Expression) (string, []any, error) { + if condition == nil { + return "", nil, nil + } + rewriter := typedAliasRewriter{aliases: aliases} + rewritten := rewriter.condition(condition) + if rewriter.groupedConstant != "" { + return "", nil, typedUnsupported("HAVING refers to %q, a grouped expression carrying a constant that the server cannot match to GROUP BY", rewriter.groupedConstant) + } + sql, args, err := c.condition(rewritten) + if err != nil { + return "", nil, fmt.Errorf("having: %w", err) + } + return " HAVING " + sql, args, nil +} + +func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelectItem, aliases map[string]dal.Expression) (string, []any, error) { + if len(orders) == 0 { + return "", nil, nil + } + rewriter := typedAliasRewriter{aliases: aliases} + parts := make([]string, len(orders)) + var args []any + for i, order := range orders { + expression := rewriter.expression(order.Expression()) + sql, values, err := c.byExpression(expression, items) + if err != nil { + return "", nil, fmt.Errorf("orderBy %d: %w", i, err) + } + parts[i] = c.dialect.orderItem(sql, order.Descending(), c.notNull(expression)) + args = append(args, values...) + } + return " ORDER BY " + strings.Join(parts, ", "), args, nil +} + +// byExpression compiles a GROUP BY or ORDER BY expression. +// +// A constant-only expression neither groups nor orders anything, and the server +// cannot infer the type of a bound parameter standing alone there, so it is +// refused. An expression that carries constants and is also selected is +// referenced by its position in the select list: the server matches GROUP BY +// and select expressions structurally, and two bound parameters never compare +// equal however equal their values. +func (c *typedCompiler) byExpression(expression dal.Expression, items []typedSelectItem) (string, []any, error) { + if typedConstantOnly(expression) { + return "", nil, typedUnsupported("a constant-only expression has no effect here") + } + sql, args, err := c.expr(expression) + if err != nil { + return "", nil, err + } + if len(args) != 0 { + if ordinal, ok := typedOrdinal(items, expression); ok { + return strconv.Itoa(ordinal), nil, nil + } + } + return sql, args, nil +} + +// typedOrdinal returns the 1-based position of the selected expression whose +// text equals target's, the same equality DALgo's aggregation validation uses. +func typedOrdinal(items []typedSelectItem, target dal.Expression) (int, bool) { + key := target.String() + for i, item := range items { + if item.expression != nil && item.expression.String() == key { + return i + 1, true + } + } + return 0, false +} + +// notNull reports whether expression is a column the catalog says is NOT NULL +// and that no outer join can null out. +func (c *typedCompiler) notNull(expression dal.Expression) bool { + field, ok := expression.(dal.FieldRef) + if !ok { + return false + } + column, identity, known := c.columnFact(field, c.sources) + return known && column.NotNull && !c.nullable[identity] +} + +// columnFact finds the catalog facts of a field, returning the identity of the +// source it belongs to. Every field reaching it was already rendered, so its +// source is visible; a name outside sources would only miss in the facts. +func (c *typedCompiler) columnFact(field dal.FieldRef, sources map[string]typedSource) (typedColumnFact, string, bool) { + identity := field.Source() + if identity == "" { + identity = c.base + } + column, ok := c.facts.column(sources[identity].table, field.Name()) + return column, identity, ok +} + +// typedRelation is a compiled FROM tree. +type typedRelation struct { + sql string + sources map[string]typedSource + nullable map[string]bool +} + +// relation renders a FROM tree. A nested right subtree is parenthesised so the +// engine keeps its LEFT/INNER grouping. The scope rules are those of the SQLite +// compiler: a subtree starts with an empty visible scope, and a nested ON that +// names an alias outside its subtree is refused because a parenthesised JOIN +// cannot represent it. outer lists the identities visible around this subtree. +func (c *typedCompiler) relation(from dal.FromSource, path string, outer map[string]struct{}) (typedRelation, error) { + text, source, err := c.table(from.Base()) + if err != nil { + return typedRelation{}, fmt.Errorf("%s: %w", path, err) + } + relation := typedRelation{ + sql: text, + sources: map[string]typedSource{typedIdentity(from.Base()): source}, + nullable: map[string]bool{}, + } + for i, join := range from.Joins() { + joinPath := fmt.Sprintf("%s.joins[%d]", path, i) + var keyword string + switch join.JoinType() { + case dal.JoinInner: + keyword = " INNER JOIN " + case dal.JoinLeft: + keyword = " LEFT JOIN " + default: + return typedRelation{}, typedUnsupported("%s.type: join_type: only INNER and LEFT joins are supported", joinPath) + } + right := join.From() + if right == nil { + right = dal.From(join.RecordsetSource) + } + scope := make(map[string]struct{}, len(outer)+len(relation.sources)) + for identity := range outer { + scope[identity] = struct{}{} + } + for identity := range relation.sources { + scope[identity] = struct{}{} + } + child, err := c.relation(right, joinPath+".from", scope) + if err != nil { + return typedRelation{}, err + } + visible := maps.Clone(relation.sources) + maps.Copy(visible, child.sources) + on, err := c.joinOn(join.On(), visible, outer, joinPath+".on") + if err != nil { + return typedRelation{}, err + } + rightSQL := child.sql + if len(right.Joins()) != 0 { + rightSQL = "(" + rightSQL + ")" + } + relation.sql += keyword + rightSQL + " ON " + on + relation.sources = visible + maps.Copy(relation.nullable, child.nullable) + if join.JoinType() == dal.JoinLeft { + for identity := range child.sources { + relation.nullable[identity] = true + } + } + } + return relation, nil +} + +// joinOn renders a join's ON list: equalities between qualified fields. When +// the catalog knows both columns it also refuses a pair whose types the server +// would not compare, so the failure is a refusal here and not a server error. +func (c *typedCompiler) joinOn(conditions []dal.Condition, visible map[string]typedSource, outer map[string]struct{}, path string) (string, error) { + if len(conditions) == 0 { + return "", typedUnsupported("%s: join_shape: ON must not be empty", path) + } + parts := make([]string, len(conditions)) + for i, condition := range conditions { + pair := fmt.Sprintf("%s[%d]", path, i) + comparison, isComparison := condition.(dal.Comparison) + left, leftIsField := comparison.Left.(dal.FieldRef) + right, rightIsField := comparison.Right.(dal.FieldRef) + if !isComparison || comparison.Operator != dal.Equal || !leftIsField || !rightIsField { + return "", typedUnsupported("%s: join_operator: a JOIN needs an equality between two fields", pair) + } + leftSQL, err := c.onField(left, visible, outer, pair+".left") + if err != nil { + return "", err + } + rightSQL, err := c.onField(right, visible, outer, pair+".right") + if err != nil { + return "", err + } + leftColumn, _, leftKnown := c.columnFact(left, visible) + rightColumn, _, rightKnown := c.columnFact(right, visible) + if leftKnown && rightKnown && !typedJoinKeysComparable(leftColumn, rightColumn) { + return "", typedUnsupported("%s: join_plan: key types differ (%q against %q)", pair, leftColumn.DataType, rightColumn.DataType) + } + parts[i] = leftSQL + " = " + rightSQL + } + return "(" + strings.Join(parts, " AND ") + ")", nil +} + +func (c *typedCompiler) onField(field dal.FieldRef, visible map[string]typedSource, outer map[string]struct{}, path string) (string, error) { + if _, correlated := outer[field.Source()]; correlated { + return "", typedUnsupported("%s: join_plan: a nested ON refers to %q outside its subtree", path, field.Source()) + } + sql, err := c.field(field, visible, true) + if err != nil { + return "", fmt.Errorf("%s: %w", path, err) + } + return sql, nil +} + +// table renders one FROM entry and describes the source it introduces. +func (c *typedCompiler) table(source dal.RecordsetSource) (string, typedSource, error) { + collection, err := typedCollection(source) + if err != nil { + return "", typedSource{}, err + } + name, err := c.quote(collection.Name()) + if err != nil { + return "", typedSource{}, fmt.Errorf("table name: %w", err) + } + text, identity := name, name + if schema := collection.Schema(); schema != "" { + quoted, err := c.quote(schema) + if err != nil { + return "", typedSource{}, fmt.Errorf("schema name: %w", err) + } + text = quoted + "." + text + } + if alias := collection.Alias(); alias != "" { + identity, err = c.quote(alias) + if err != nil { + return "", typedSource{}, fmt.Errorf("source alias: %w", err) + } + text += " AS " + identity + } + return text, typedSource{quoted: identity, table: typedSourceName{Schema: collection.Schema(), Name: collection.Name()}}, nil +} + +// typedCollection accepts the one source kind this compiler can render: a root +// collection (a table) in the connected database. +func typedCollection(source dal.RecordsetSource) (dal.CollectionRef, error) { + var collection dal.CollectionRef + switch typed := source.(type) { + case dal.CollectionRef: + collection = typed + case *dal.CollectionRef: + if typed == nil { + return dal.CollectionRef{}, typedUnsupported("source %T is nil", source) + } + collection = *typed + default: + return dal.CollectionRef{}, typedUnsupported("source %T", source) + } + if collection.Parent() != nil { + return dal.CollectionRef{}, typedUnsupported("parented collection sources") + } + return collection, nil +} + +// typedIdentity is the name a source goes by inside the statement. +func typedIdentity(source dal.RecordsetSource) string { + if alias := source.Alias(); alias != "" { + return alias + } + return source.Name() +} + +// typedQuerySources lists, without duplicates and in FROM order, the tables a +// query reads. The reader passes them to typedDialect.catalogFacts. A source +// the compiler would refuse is skipped; compileTypedSQL reports it. +func typedQuerySources(from dal.FromSource) []typedSourceName { + var names []typedSourceName + seen := map[typedSourceName]bool{} + var walk func(dal.FromSource) + walk = func(node dal.FromSource) { + if node == nil { + return + } + if collection, err := typedCollection(node.Base()); err == nil { + name := typedSourceName{Schema: collection.Schema(), Name: collection.Name()} + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + for _, join := range node.Joins() { + child := join.From() + if child == nil { + child = dal.From(join.RecordsetSource) + } + walk(child) + } + } + walk(from) + return names +} + +// field renders a field reference. Inside a JOIN every field must name its +// source; elsewhere an unqualified field belongs to the single FROM source. +func (c *typedCompiler) field(field dal.FieldRef, sources map[string]typedSource, requireQualified bool) (string, error) { + name, err := c.quote(field.Name()) + if err != nil { + return "", err + } + if field.Source() == "" { + if requireQualified { + return "", typedUnsupported("field %q must name a source in a JOIN query", field.Name()) + } + return name, nil + } + source, ok := sources[field.Source()] + if !ok { + return "", fmt.Errorf("field %q references unknown source %q", field.Name(), field.Source()) + } + return source.quoted + "." + name, nil +} + +// quote is the only way an identifier reaches the SQL text. The dialect +// validates and quotes; the compiler then checks the result is one well-formed +// quoted identifier, so a faulty dialect cannot let a name escape its quotes. +func (c *typedCompiler) quote(name string) (string, error) { + quoted, err := c.dialect.quoteIdent(name) + if err != nil { + return "", err + } + if !isQuotedTypedIdentifier(quoted, c.dialect.placeholderStyle().IdentQuote) { + return "", errors.New("typed SQL: the dialect returned a malformed quoted identifier") + } + return quoted, nil +} + +// bind turns one constant into its marker and argument. +func (c *typedCompiler) bind(value any) (string, []any, error) { + if _, err := typedKindOf(value); err != nil { + return "", nil, err + } + marker, arg, err := c.dialect.bind(value) + if err != nil { + return "", nil, err + } + return marker, []any{arg}, nil +} + +func (c *typedCompiler) expr(expression dal.Expression) (string, []any, error) { + switch e := expression.(type) { + case dal.FieldRef: + sql, err := c.field(e, c.sources, c.hasJoins) + return sql, nil, err + case dal.Constant: + return c.bind(e.Value) + case dal.AggregateFunc: + return c.aggregate(e) + case dal.BinaryExpression: + return c.binary(e) + default: + return "", nil, typedUnsupported("expression %T", expression) + } +} + +func (c *typedCompiler) aggregate(function dal.AggregateFunc) (string, []any, error) { + if c.noAggregates { + return "", nil, errors.New("aggregate functions are not allowed in WHERE") + } + // The function name is emitted from the whitelist below, never from the + // caller's spelling. + name := strings.ToUpper(function.FuncName()) + switch name { + case dal.COUNT, dal.SUM, dal.AVERAGE, dal.MIN, dal.MAX: + case dal.FIRST, dal.LAST: + return "", nil, typedUnsupported("%s needs a deterministic row order that SQL aggregation does not provide", name) + default: + return "", nil, typedUnsupported("an aggregate function DALgo does not define") + } + arguments := function.FuncArgs() + if len(arguments) != 1 { + return "", nil, fmt.Errorf("%s requires exactly one argument", name) + } + argument, args := "*", []any(nil) + if star, ok := arguments[0].(dal.StarExpression); !ok || !star.IsStar() { + var err error + if argument, args, err = c.expr(arguments[0]); err != nil { + return "", nil, err + } + } + distinct := "" + if d, ok := function.(dal.DistinctAggregateFunc); ok && d.IsDistinct() { + distinct = "DISTINCT " + } + return c.dialect.aggregateResult(name, name+"("+distinct+argument+")"), args, nil +} + +func (c *typedCompiler) binary(expression dal.BinaryExpression) (string, []any, error) { + switch expression.Operator { + case dal.Add, dal.Subtract, dal.Multiply, dal.Divide: + default: + return "", nil, typedUnsupported("arithmetic operator %q", expression.Operator) + } + left, leftArgs, err := c.expr(expression.Left) + if err != nil { + return "", nil, err + } + right, rightArgs, err := c.expr(expression.Right) + if err != nil { + return "", nil, err + } + args := slices.Concat(leftArgs, rightArgs) + if expression.Operator == dal.Divide { + return c.dialect.divide(left, right), args, nil + } + return "(" + left + " " + string(expression.Operator) + " " + right + ")", args, nil +} + +func (c *typedCompiler) condition(condition dal.Condition) (string, []any, error) { + switch cond := condition.(type) { + case dal.Comparison: + return c.comparison(cond) + case dal.IsNullCondition: + return c.isNull(cond) + case dal.GroupCondition: + return c.group(cond) + default: + return "", nil, typedUnsupported("condition %T", condition) + } +} + +var typedComparisonOperators = map[dal.Operator]string{ + dal.Equal: "=", + dal.GreaterThen: ">", + dal.GreaterOrEqual: ">=", + dal.LessThen: "<", + dal.LessOrEqual: "<=", +} + +// comparison renders one comparison. `== null` is IS NULL, as in DALgo's other +// adapters; every other comparison against a bound NULL stays UNKNOWN, which is +// what SQL three-valued logic gives and what DALgo's generic engine does for +// WHERE. +func (c *typedCompiler) comparison(comparison dal.Comparison) (string, []any, error) { + if typedConstantOnly(comparison.Left) { + return "", nil, typedUnsupported("a comparison needs a field or aggregate on the left") + } + left, args, err := c.expr(comparison.Left) + if err != nil { + return "", nil, err + } + if dal.IsGroupOperator(comparison.Operator) { + return c.membership(left, args, comparison) + } + operator, ok := typedComparisonOperators[comparison.Operator] + if !ok { + return "", nil, typedUnsupported("comparison operator %q", comparison.Operator) + } + if constant, isConstant := comparison.Right.(dal.Constant); isConstant && constant.Value == nil && comparison.Operator == dal.Equal { + return left + " IS NULL", args, nil + } + right, rightArgs, err := c.expr(comparison.Right) + if err != nil { + return "", nil, err + } + return left + " " + operator + " " + right, slices.Concat(args, rightArgs), nil +} + +// membership renders IN and NOT IN. The engine's own semantics are the right +// ones: a NULL in the list makes a non-match UNKNOWN, so NULL IN (NULL) is not +// true and its negation is not true either. +func (c *typedCompiler) membership(left string, leftArgs []any, comparison dal.Comparison) (string, []any, error) { + array, ok := comparison.Right.(dal.Array) + if !ok { + return "", nil, typedUnsupported("%s needs an array on the right", comparison.Operator) + } + values, err := typedArrayValues(array.Value) + if err != nil { + return "", nil, err + } + negated := comparison.Operator == dal.NotIn + if len(values) == 0 { + return c.dialect.emptyIn(negated), nil, nil + } + if len(values) > typedMaxArguments { + return "", nil, typedUnsupported("an IN list of %d values is too many to bind", len(values)) + } + markers := make([]string, len(values)) + args := slices.Clone(leftArgs) + for i, value := range values { + marker, valueArgs, err := c.bind(value) + if err != nil { + return "", nil, fmt.Errorf("IN value %d: %w", i, err) + } + markers[i] = marker + args = append(args, valueArgs...) + } + keyword := " IN (" + if negated { + keyword = " NOT IN (" + } + return left + keyword + strings.Join(markers, ", ") + ")", args, nil +} + +func typedArrayValues(value any) ([]any, error) { + array := reflect.ValueOf(value) + if !array.IsValid() || (array.Kind() != reflect.Slice && array.Kind() != reflect.Array) { + return nil, typedUnsupported("IN array has type %T", value) + } + values := make([]any, array.Len()) + for i := range values { + values[i] = array.Index(i).Interface() + } + return values, nil +} + +func (c *typedCompiler) isNull(condition dal.IsNullCondition) (string, []any, error) { + if typedConstantOnly(condition.Operand()) { + return "", nil, typedUnsupported("IS NULL needs a field or aggregate, not a constant") + } + operand, args, err := c.expr(condition.Operand()) + if err != nil { + return "", nil, err + } + if condition.Negated() { + return operand + " IS NOT NULL", args, nil + } + return operand + " IS NULL", args, nil +} + +func (c *typedCompiler) group(group dal.GroupCondition) (string, []any, error) { + var operator string + switch group.Operator() { + case dal.And: + operator = "AND" + case dal.Or: + operator = "OR" + default: + return "", nil, typedUnsupported("group operator %q", group.Operator()) + } + children := group.Conditions() + if len(children) == 0 { + return "", nil, errors.New("empty condition group") + } + parts := make([]string, len(children)) + var args []any + for i, child := range children { + part, childArgs, err := c.condition(child) + if err != nil { + return "", nil, err + } + parts[i] = part + args = append(args, childArgs...) + } + return "(" + strings.Join(parts, " "+operator+" ") + ")", args, nil +} + +// typedConstantOnly reports whether an expression is built from constants +// alone, with no field and no aggregate. +func typedConstantOnly(expression dal.Expression) bool { + switch e := expression.(type) { + case dal.Constant: + return true + case dal.BinaryExpression: + return typedConstantOnly(e.Left) && typedConstantOnly(e.Right) + } + return false +} + +func typedContainsAggregate(expression dal.Expression) bool { + switch e := expression.(type) { + case dal.AggregateFunc: + return true + case dal.BinaryExpression: + return typedContainsAggregate(e.Left) || typedContainsAggregate(e.Right) + } + return false +} + +// typedContainsConstant reports a constant outside any aggregate argument. +func typedContainsConstant(expression dal.Expression) bool { + switch e := expression.(type) { + case dal.Constant: + return true + case dal.BinaryExpression: + return typedContainsConstant(e.Left) || typedContainsConstant(e.Right) + } + return false +} + +// typedAliasRewriter replaces unqualified references to select aliases with the +// aliased expression. The replacement is not rewritten again, so an alias that +// shares a name with a column it selects cannot loop. +type typedAliasRewriter struct { + aliases map[string]dal.Expression + // groupedConstant is the first alias that stood for a non-aggregate + // expression carrying a constant, which can only be a grouped expression. + groupedConstant string +} + +func (r *typedAliasRewriter) expression(expression dal.Expression) dal.Expression { + switch e := expression.(type) { + case dal.FieldRef: + if replacement, ok := r.aliases[e.Name()]; ok && e.Source() == "" { + if r.groupedConstant == "" && !typedContainsAggregate(replacement) && typedContainsConstant(replacement) { + r.groupedConstant = e.Name() + } + return replacement + } + case dal.BinaryExpression: + e.Left = r.expression(e.Left) + e.Right = r.expression(e.Right) + return e + } + return expression +} + +func (r *typedAliasRewriter) condition(condition dal.Condition) dal.Condition { + switch c := condition.(type) { + case dal.Comparison: + c.Left = r.expression(c.Left) + c.Right = r.expression(c.Right) + return c + case dal.IsNullCondition: + operand := r.expression(c.Operand()) + if c.Negated() { + return dal.NewIsNotNullCondition(operand) + } + return dal.NewIsNullCondition(operand) + case dal.GroupCondition: + children := make([]dal.Condition, len(c.Conditions())) + for i, child := range c.Conditions() { + children[i] = r.condition(child) + } + return dal.NewGroupCondition(c.Operator(), children...) + } + return condition +} diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go new file mode 100644 index 0000000..d48c127 --- /dev/null +++ b/typed_sql_errors_test.go @@ -0,0 +1,341 @@ +package dalgo2sql + +import ( + "errors" + "strings" + "testing" + + "github.com/dal-go/dalgo/dal" + "github.com/dal-go/record" +) + +type fakeTypedCondition struct{} + +func (fakeTypedCondition) String() string { return "fake" } + +func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) { + album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } + whereBy := func(c dal.Condition) dal.StructuredQuery { return album().Where(c).SelectColumns() } + selectOf := func(e dal.Expression) dal.StructuredQuery { + return album().SelectColumns(typedTestColumn(e, "x")) + } + stableFirstLast := newFakeTypedDialect() + stableFirstLast.caps.StableRowOrder = true + stableFirstLast.caps.Aggregate.First = true + stableFirstLast.caps.Aggregate.Last = true + noMin := newFakeTypedDialect() + noMin.caps.Aggregate.Min = false + noGroupBy := newFakeTypedDialect() + noGroupBy.caps.GroupBy = false + parent := record.NewKeyWithID("Parent", "p1") + + cases := []struct { + name string + query dal.StructuredQuery + dialect typedDialect + fragment string + }{ + {"start from cursor", album().StartFrom("c").SelectColumns(), nil, "cursors"}, + {"start after cursor", album().StartAfter("c").SelectColumns(), nil, "cursors"}, + {"subquery source", dal.From(dal.NewQuerySource(album().SelectColumns(), "s")).NewQuery().SelectColumns(), nil, "subqueries"}, + {"FIRST without a stable order", album().SelectColumns(dal.FirstAs(typedTestField("a"), "f")), nil, "stable"}, + {"LAST without a stable order", album().SelectColumns(dal.LastAs(typedTestField("a"), "l")), nil, "stable"}, + {"FIRST even when the dialect claims support", album().SelectColumns(dal.FirstAs(typedTestField("a"), "f")), stableFirstLast, "FIRST"}, + {"LAST even when the dialect claims support", album().SelectColumns(dal.LastAs(typedTestField("a"), "l")), stableFirstLast, "LAST"}, + {"aggregate the dialect cannot run natively", album().SelectColumns(dal.MinAs(typedTestField("a"), "m")), noMin, "natively"}, + {"grouping the dialect cannot run natively", album().GroupBy(typedTestField("a")).SelectColumns(), noGroupBy, "natively"}, + {"right join", typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinRight, typedTestJoinOn("a", "x", "r", "y"))).NewQuery().SelectColumns(), nil, "join_type"}, + {"non-equality join", typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner, dal.NewComparison(typedTestQualified("a", "x"), dal.GreaterThen, typedTestQualified("r", "y")))).NewQuery().SelectColumns(), nil, "join_operator"}, + {"collection group source", dal.From(dal.NewCollectionGroupRef("Album", "")).NewQuery().SelectColumns(), nil, "source"}, + {"parented collection", dal.From(dal.NewCollectionRef("Child", "", parent)).NewQuery().SelectColumns(), nil, "parented"}, + {"nil collection pointer", dal.From((*dal.CollectionRef)(nil)).NewQuery().SelectColumns(), nil, "source"}, + {"unqualified field in a join", typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner, typedTestJoinOn("a", "x", "r", "y"))).NewQuery().SelectColumns(typedTestColumn(typedTestField("Title"), "")), nil, "must name a source"}, + {"param expression", whereBy(dal.NewComparison(typedTestField("a"), dal.Equal, dal.NewParam("p"))), nil, "expression"}, + {"array outside IN", whereBy(dal.NewComparison(typedTestField("a"), dal.Equal, dal.NewArray([]int{1}))), nil, "expression"}, + {"nil select expression", album().SelectColumns(dal.Column{}), nil, "expression"}, + {"unsupported arithmetic operator", selectOf(dal.Binary(typedTestField("a"), dal.ArithmeticOperator("%"), typedTestField("b"))), nil, "arithmetic operator"}, + {"unsupported value type", whereBy(typedTestEq(typedTestField("a"), struct{}{})), nil, "SQL value type"}, + {"unsupported value type in a list", whereBy(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray([]any{1, map[string]int{}}))), nil, "SQL value type"}, + {"unaliased constant carrying expression", album().SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), "")), nil, "needs an alias"}, + {"comparison with a constant on the left", whereBy(dal.NewComparison(typedTestConst(1), dal.Equal, typedTestField("a"))), nil, "on the left"}, + {"array membership of a stored array", album().WhereInArrayField("tags", "x").SelectColumns(), nil, "on the left"}, + {"unsupported comparison operator", whereBy(dal.NewComparison(typedTestField("a"), dal.Operator("like"), typedTestConst("x"))), nil, "comparison operator"}, + {"IN needs an array", whereBy(dal.NewComparison(typedTestField("a"), dal.In, typedTestConst(1))), nil, "array"}, + {"NOT IN needs an array", whereBy(dal.NewComparison(typedTestField("a"), dal.NotIn, typedTestField("b"))), nil, "array"}, + {"IN over a non-slice array", whereBy(dal.NewComparison(typedTestField("a"), dal.In, dal.Array{Value: 5})), nil, "array has type"}, + {"IN over a nil array", whereBy(dal.NewComparison(typedTestField("a"), dal.In, dal.Array{})), nil, "array has type"}, + {"IN over too many values", whereBy(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray(make([]int, typedMaxArguments+1)))), nil, "too many"}, + {"unsupported group operator", whereBy(dal.NewGroupCondition(dal.Operator("XOR"), typedTestEq(typedTestField("a"), 1))), nil, "group operator"}, + {"unsupported condition type", whereBy(fakeTypedCondition{}), nil, "condition"}, + {"nil child condition", whereBy(dal.NewGroupCondition(dal.And, nil)), nil, "condition"}, + {"IS NULL without an operand", whereBy(dal.NewIsNullCondition(nil)), nil, "expression"}, + {"IS NULL of a constant", whereBy(dal.NewIsNullCondition(typedTestConst(1))), nil, "constant"}, + {"constant-only GROUP BY", album().GroupBy(typedTestConst(1)).SelectColumns(dal.Count()), nil, "constant"}, + {"constant-only ORDER BY", album().OrderBy(dal.Ascending(typedTestConst(1))).SelectColumns(), nil, "constant"}, + { + "HAVING over a grouped alias that carries a constant", + album().GroupBy(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1))). + Having(dal.NewComparison(typedTestField("bucket"), dal.GreaterThen, typedTestConst(2))). + SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), "bucket"), dal.CountAs(typedTestField("a"), "n")), + nil, "grouped expression", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectTypedUnsupported(t, tc.query, tc.dialect, typedCatalogFacts{}, tc.fragment) + }) + } +} + +func TestCompileTypedSQLRejectsInvalidQueriesWithoutClaimingUnsupported(t *testing.T) { + album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } + longName := strings.Repeat("n", 64) + cases := []struct { + name string + query dal.StructuredQuery + dialect typedDialect + fragment string + }{ + {"no source", dal.NewQueryBuilder(nil).SelectColumns(), nil, "requires a source"}, + {"nil query", nil, nil, "requires a source"}, + {"negative limit", album().Limit(-1).SelectColumns(), nil, "non-negative"}, + {"negative offset", album().Offset(-1).SelectColumns(), nil, "non-negative"}, + {"ungrouped column", album().GroupBy(typedTestField("a")).SelectColumns(typedTestColumn(typedTestField("b"), "")), nil, "invalid aggregation"}, + {"empty join ON", typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner)).NewQuery().SelectColumns(), nil, "join_shape"}, + {"unknown source", album().SelectColumns(typedTestColumn(typedTestQualified("zzz", "a"), "")), nil, "unknown source"}, + {"empty field name", album().SelectColumns(typedTestColumn(typedTestField(""), "")), nil, "empty"}, + {"NUL in a field name", album().SelectColumns(typedTestColumn(typedTestField("a\x00b"), "")), nil, "NUL"}, + {"invalid UTF-8 in an alias", album().SelectColumns(typedTestColumn(typedTestField("a"), "a\xffb")), nil, "UTF-8"}, + {"table name over the limit", typedTestFrom(longName, "").NewQuery().SelectColumns(), nil, "limit"}, + {"schema name over the limit", dal.From(dal.NewQualifiedRootCollectionRef(longName, "t", "")).NewQuery().SelectColumns(), nil, "limit"}, + {"source alias over the limit", typedTestFrom("t", longName).NewQuery().SelectColumns(), nil, "limit"}, + {"unaliased expression whose text is too long", album().SelectColumns(typedTestColumn(dal.Binary(typedTestField(strings.Repeat("a", 40)), dal.Add, typedTestField(strings.Repeat("b", 40))), "")), nil, "limit"}, + {"aggregate in WHERE", album().Where(dal.NewComparison(dal.NewAggregate(dal.COUNT, false, dal.Star()), dal.GreaterThen, typedTestConst(1))).SelectColumns(), nil, "aggregate"}, + {"empty condition group", album().Where(dal.NewGroupCondition(dal.And)).SelectColumns(), nil, "empty condition group"}, + {"dialect refuses a value", album().Where(typedTestEq(typedTestField("a"), fakeTypedFailingValuer{})).SelectColumns(), nil, "refuses"}, + {"dialect refuses a listed value", album().Where(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray([]any{fakeTypedFailingValuer{}}))).SelectColumns(), nil, "refuses"}, + {"bad field name inside GROUP BY", album().GroupBy(typedTestField("")).SelectColumns(dal.Count()), nil, "groupBy 0"}, + {"bad field name inside ORDER BY", album().OrderBy(dal.Ascending(typedTestField(""))).SelectColumns(), nil, "orderBy 0"}, + {"bad field name inside HAVING", album().Having(dal.NewComparison(dal.NewAggregate(dal.MIN, false, typedTestField("")), dal.GreaterThen, typedTestConst(1))).SelectColumns(typedTestColumn(dal.NewAggregate(dal.COUNT, false, dal.Star()), "n")), nil, "having"}, + {"bad field name inside WHERE", album().Where(typedTestEq(typedTestField(""), 1)).SelectColumns(), nil, "where"}, + {"bad column", album().SelectColumns(typedTestColumn(typedTestField(""), "x")), nil, "column 0"}, + {"bad nested join", typedTestFrom("Album", "a").Join(dal.NewJoinedFrom(dal.From(dal.NewRootCollectionRef(longName, "r")), dal.JoinInner, typedTestJoinOn("a", "x", "r", "y"))).NewQuery().SelectColumns(), nil, "joins[0].from"}, + {"bad IS NULL operand", album().Where(dal.NewIsNullCondition(typedTestField(""))).SelectColumns(), nil, "where"}, + {"bad aggregate argument", album().SelectColumns(typedTestColumn(dal.NewAggregate(dal.SUM, false, typedTestField("")), "s")), nil, "column 0"}, + {"bad arithmetic operand", album().SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestField("")), "s")), nil, "column 0"}, + {"bad left arithmetic operand", album().SelectColumns(typedTestColumn(dal.Binary(typedTestField(""), dal.Add, typedTestField("a")), "s")), nil, "column 0"}, + {"bad group condition child", album().Where(dal.NewGroupCondition(dal.And, typedTestEq(typedTestField("a"), 1), typedTestEq(typedTestField(""), 1))).SelectColumns(), nil, "where"}, + {"bad comparison right operand", album().Where(dal.NewComparison(typedTestField("a"), dal.Equal, typedTestField(""))).SelectColumns(), nil, "where"}, + {"bad IN left operand", album().Where(dal.NewComparison(typedTestField(""), dal.In, dal.NewArray([]int{1}))).SelectColumns(), nil, "where"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectTypedInvalid(t, tc.query, tc.dialect, typedCatalogFacts{}, tc.fragment) + }) + } +} + +func TestCompileTypedSQLWithoutDialect(t *testing.T) { + text, args, err := compileTypedSQL(typedTestFrom("Album", "").NewQuery().SelectColumns(), nil, typedCatalogFacts{}) + if err == nil || !strings.Contains(err.Error(), "requires a dialect") || text != "" || args != nil { + t.Fatalf("compileTypedSQL(nil dialect) = %q, %v, %v", text, args, err) + } +} + +func TestCompileTypedSQLKeepsTheDialectErrorInTheChain(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery().Where(typedTestEq(typedTestField("a"), fakeTypedFailingValuer{})).SelectColumns() + _, _, err := compileTypedSQL(q, newFakeTypedDialect(), typedCatalogFacts{}) + if !errors.Is(err, errFakeTypedBind) { + t.Fatalf("error %v does not wrap the dialect error", err) + } +} + +func TestCompileTypedSQLCatchesADialectThatBreaksTheMarkerContract(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery().Where(typedTestEq(typedTestField("a"), 1)).SelectColumns() + t.Run("two markers for one value", func(t *testing.T) { + dialect := newFakeTypedDialect() + dialect.bindMarkers = "? + ?" + _, _, err := compileTypedSQL(q, dialect, typedCatalogFacts{}) + if err == nil || !strings.Contains(err.Error(), "placeholder") { + t.Fatalf("error = %v, want a placeholder count error", err) + } + }) + // The compiler verifies every identifier a dialect hands back, so no faulty + // dialect can let a name escape its quotes into the statement. + malformed := map[string]string{ + "an opening quote only": `"Album`, + "an undoubled inner quote": `"Al"bum"`, + "a NUL byte": "\"Al\x00bum\"", + "nothing between the quotes": `""`, + "no quotes at all": `Album`, + "a lone quote": `"`, + "an escaped closing quote": `"Album""`, + } + for name, quoted := range malformed { + t.Run("an identifier with "+name, func(t *testing.T) { + dialect := newFakeTypedDialect() + dialect.quoteOverride = func(string) (string, error) { return quoted, nil } + text, args, err := compileTypedSQL(typedTestFrom("Album", "").NewQuery().SelectColumns(), dialect, typedCatalogFacts{}) + if err == nil || !strings.Contains(err.Error(), "malformed quoted identifier") || text != "" || args != nil { + t.Fatalf("compileTypedSQL() = %q, %v, %v; want a malformed quoted identifier error", text, args, err) + } + }) + } + t.Run("a dialect error from quoteIdent is passed through", func(t *testing.T) { + boom := errors.New("quote failed") + dialect := newFakeTypedDialect() + dialect.quoteOverride = func(string) (string, error) { return "", boom } + if _, _, err := compileTypedSQL(typedTestFrom("Album", "").NewQuery().SelectColumns(), dialect, typedCatalogFacts{}); !errors.Is(err, boom) { + t.Fatalf("error = %v, want the dialect's error", err) + } + }) +} + +func TestCompileTypedSQLCheckedJoinKeyTypes(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Name: "Album"}: {Columns: []typedColumnFact{ + {Name: "ArtistId", DataType: "int4", Category: typedTypeNumber}, + {Name: "Guid", DataType: "uuid", Category: typedTypeOther}, + {Name: "Mystery", DataType: "mystery", Category: typedTypeUnknown}, + {Name: "Blank", Category: typedTypeUnknown}, + }}, + {Name: "Artist"}: {Columns: []typedColumnFact{ + {Name: "ArtistId", DataType: "int8", Category: typedTypeNumber}, + {Name: "Name", DataType: "text", Category: typedTypeText}, + {Name: "Guid", DataType: "uuid", Category: typedTypeOther}, + {Name: "Doc", DataType: "json", Category: typedTypeOther}, + {Name: "Mystery", DataType: "mystery", Category: typedTypeUnknown}, + }}, + }} + join := func(albumField, artistField string) dal.StructuredQuery { + return typedTestFrom("Album", "a").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner, typedTestJoinOn("a", albumField, "r", artistField)), + ).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("r", "Name"), "")) + } + accepted := []struct{ name, album, artist string }{ + {"same category different widths", "ArtistId", "ArtistId"}, + {"same non-scalar type", "Guid", "Guid"}, + {"same unknown type name", "Mystery", "Mystery"}, + {"column missing from the facts is left to the server", "Missing", "ArtistId"}, + {"other side missing from the facts", "ArtistId", "Missing"}, + } + for _, tc := range accepted { + t.Run("accepts "+tc.name, func(t *testing.T) { + if _, _, err := compileTypedSQL(join(tc.album, tc.artist), newFakeTypedDialect(), facts); err != nil { + t.Fatalf("compileTypedSQL() error = %v", err) + } + }) + } + refused := []struct{ name, album, artist string }{ + {"number against text", "ArtistId", "Name"}, + {"two different non-scalar types", "Guid", "Doc"}, + {"unknown against known", "Mystery", "Name"}, + {"unknown without a type name", "Blank", "Mystery"}, + } + for _, tc := range refused { + t.Run("refuses "+tc.name, func(t *testing.T) { + expectTypedUnsupported(t, join(tc.album, tc.artist), nil, facts, "join_plan") + }) + } + t.Run("without facts nothing is checked", func(t *testing.T) { + if _, _, err := compileTypedSQL(join("ArtistId", "Name"), newFakeTypedDialect(), typedCatalogFacts{}); err != nil { + t.Fatalf("compileTypedSQL() error = %v", err) + } + }) +} + +func typedTestAlbumArtistSources() map[string]typedSource { + return map[string]typedSource{ + "a": {quoted: `"a"`, table: typedSourceName{Name: "Album"}}, + "r": {quoted: `"r"`, table: typedSourceName{Name: "Artist"}}, + } +} + +func TestTypedAliasRewriterLeavesUnknownConditionsAlone(t *testing.T) { + rewriter := typedAliasRewriter{aliases: map[string]dal.Expression{"x": typedTestField("y")}} + if got := rewriter.condition(fakeTypedCondition{}); got != (fakeTypedCondition{}) { + t.Fatalf("condition() = %v, want the condition unchanged", got) + } +} + +func TestTypedCompilerDirectGuards(t *testing.T) { + newCompiler := func() *typedCompiler { + return &typedCompiler{dialect: newFakeTypedDialect(), sources: typedTestAlbumArtistSources()} + } + t.Run("an aggregate name outside the whitelist is never emitted", func(t *testing.T) { + _, _, err := newCompiler().aggregate(dal.NewAggregate("MEDIAN; DROP TABLE x", false, typedTestField("a"))) + if !errors.Is(err, dal.ErrNotSupported) || strings.Contains(err.Error(), "DROP") { + t.Fatalf("error = %v, want ErrNotSupported without echoing the name", err) + } + }) + t.Run("an aggregate needs exactly one argument", func(t *testing.T) { + _, _, err := newCompiler().aggregate(dal.NewAggregate(dal.SUM, false)) + if err == nil || errors.Is(err, dal.ErrNotSupported) { + t.Fatalf("error = %v, want a plain arity error", err) + } + }) + joinOnCases := []struct { + name string + conditions []dal.Condition + }{ + {"empty", nil}, + {"not a comparison", []dal.Condition{fakeTypedCondition{}}}, + {"not an equality", []dal.Condition{dal.NewComparison(typedTestQualified("a", "x"), dal.LessThen, typedTestQualified("r", "y"))}}, + {"left operand not a field", []dal.Condition{dal.NewComparison(typedTestConst(1), dal.Equal, typedTestQualified("r", "y"))}}, + {"right operand not a field", []dal.Condition{dal.NewComparison(typedTestQualified("a", "x"), dal.Equal, typedTestConst(1))}}, + } + for _, tc := range joinOnCases { + t.Run("ON "+tc.name, func(t *testing.T) { + _, err := newCompiler().joinOn(tc.conditions, typedTestAlbumArtistSources(), nil, "from.joins[0].on") + if !errors.Is(err, dal.ErrNotSupported) { + t.Fatalf("error = %v, want ErrNotSupported", err) + } + }) + } + emptyFieldCases := map[string]dal.Condition{ + "left": typedTestJoinOn("a", "", "r", "y"), + "right": typedTestJoinOn("a", "x", "r", ""), + } + for side, condition := range emptyFieldCases { + t.Run("ON "+side+" field with an empty name", func(t *testing.T) { + _, err := newCompiler().joinOn([]dal.Condition{condition}, typedTestAlbumArtistSources(), nil, "from.joins[0].on") + if err == nil || errors.Is(err, dal.ErrNotSupported) || !strings.Contains(err.Error(), "from.joins[0].on[0]."+side) { + t.Fatalf("error = %v, want a plain path-qualified error", err) + } + }) + } + t.Run("ON field outside the visible scope", func(t *testing.T) { + _, err := newCompiler().joinOn([]dal.Condition{typedTestJoinOn("a", "x", "zzz", "y")}, map[string]typedSource{"a": {quoted: `"a"`, table: typedSourceName{Name: "Album"}}}, nil, "from.joins[0].on") + if err == nil || !strings.Contains(err.Error(), "from.joins[0].on[0].right") { + t.Fatalf("error = %v, want a path-qualified scope error", err) + } + }) +} + +func TestTypedCompilerRelationRefusesAJoinTypeItCannotRender(t *testing.T) { + // compileTypedSQL stops these earlier through dal.ValidateJoinTree; the + // renderer keeps its own guard so it never emits INNER for a RIGHT join. + from := typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinRight, typedTestJoinOn("a", "x", "r", "y"))) + c := &typedCompiler{dialect: newFakeTypedDialect()} + if _, err := c.relation(from, "from", nil); !errors.Is(err, dal.ErrNotSupported) || !strings.Contains(err.Error(), "from.joins[0].type") { + t.Fatalf("relation() error = %v, want an unsupported join_type error", err) + } +} + +func TestTypedQuerySources(t *testing.T) { + artists := dal.From(dal.NewQualifiedRootCollectionRef("s", "Artist", "r")).Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Album", "b"), dal.JoinInner, typedTestJoinOn("r", "x", "b", "y")), + ) + from := typedTestFrom("Album", "a"). + Join(dal.NewJoinedFrom(artists, dal.JoinLeft, typedTestJoinOn("a", "x", "r", "y"))). + Join(dal.NewJoinedSource(dal.NewQuerySource(typedTestFrom("Q", "").NewQuery().SelectColumns(), "q"), dal.JoinInner, typedTestJoinOn("a", "x", "q", "y"))). + Join(dal.NewJoinedSource(dal.NewCollectionGroupRef("G", ""), dal.JoinInner, typedTestJoinOn("a", "x", "g", "y"))) + want := []typedSourceName{{Name: "Album"}, {Schema: "s", Name: "Artist"}} + got := typedQuerySources(from) + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("typedQuerySources() = %v, want %v (duplicates removed, unrenderable sources skipped)", got, want) + } + if got := typedQuerySources(nil); len(got) != 0 { + t.Fatalf("typedQuerySources(nil) = %v, want none", got) + } +} diff --git a/typed_sql_property_test.go b/typed_sql_property_test.go new file mode 100644 index 0000000..b7bf82c --- /dev/null +++ b/typed_sql_property_test.go @@ -0,0 +1,164 @@ +package dalgo2sql + +import ( + "fmt" + "math/rand/v2" + "reflect" + "strings" + "testing" + "time" + + "github.com/dal-go/dalgo/dal" +) + +// typedDraw is one random assignment of values to every constant position of +// typedPropertyQuery. Types are fixed per position because a dialect may type a +// placeholder by the Go type of its value; only the values themselves vary. +type typedDraw struct { + i1, i2, i3, i4 int + f1, f2 float64 + s1, s2, s3 string + flag bool + stamp time.Time + blob []byte + limit, offset int +} + +// A marker that cannot occur in the compiler's own text, plus characters that +// would matter if a value were ever spliced into SQL. +const typedHostileAlphabet = `'"?$;-\()` + "\n" + +func drawTypedValues(r *rand.Rand) typedDraw { + text := func() string { + var b strings.Builder + b.WriteString("§value-") + for range r.IntN(12) { + b.WriteByte(typedHostileAlphabet[r.IntN(len(typedHostileAlphabet))]) + b.WriteByte(byte('a' + r.IntN(26))) + } + return b.String() + } + blob := make([]byte, r.IntN(8)) + for i := range blob { + blob[i] = byte(r.IntN(256)) + } + return typedDraw{ + i1: r.IntN(1 << 30), i2: -r.IntN(1 << 30), i3: r.IntN(5), i4: r.IntN(1 << 20), + f1: r.NormFloat64() * 1e6, f2: r.Float64(), + s1: text(), s2: text(), s3: text(), + flag: r.IntN(2) == 0, + stamp: time.Unix(r.Int64N(4e9), r.Int64N(1e9)).UTC(), + blob: blob, + limit: 1 + r.IntN(1000), + offset: 1 + r.IntN(1000), + } +} + +// typedPropertyQuery touches every construct that carries a constant: select-list +// arithmetic, comparisons of every operator class, IN and NOT IN, a grouped +// expression referenced by ordinal, HAVING through an alias, ORDER BY through +// an alias, LIMIT and OFFSET. +func typedPropertyQuery(d typedDraw) dal.StructuredQuery { + bucket := dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(d.i1)) + return typedTestFrom("Invoice", "").NewQuery(). + Where(dal.NewGroupCondition(dal.And, + typedTestEq(typedTestField("a"), d.i2), + dal.NewComparison(typedTestField("b"), dal.GreaterThen, typedTestConst(d.f1)), + typedTestEq(typedTestField("c"), d.s1), + typedTestEq(typedTestField("d"), d.flag), + dal.NewComparison(typedTestField("e"), dal.LessOrEqual, typedTestConst(d.stamp)), + typedTestEq(typedTestField("f"), d.blob), + dal.NewComparison(typedTestField("g"), dal.In, dal.NewArray([]any{d.i3, d.s2, d.f2})), + dal.NewComparison(typedTestField("h"), dal.NotIn, dal.NewArray([]string{d.s3})), + )). + GroupBy(bucket). + Having(dal.NewComparison(typedTestField("total"), dal.GreaterThen, typedTestConst(d.i4))). + OrderBy(dal.Ascending(typedTestField("bucket"))). + Limit(d.limit).Offset(d.offset). + SelectColumns(typedTestColumn(bucket, "bucket"), dal.SumAs(typedTestField("Total"), "total")) +} + +func (d typedDraw) wantArgs() []any { + return []any{ + d.i1, + d.i2, d.f1, d.s1, d.flag, d.stamp, d.blob, d.i3, d.s2, d.f2, d.s3, + d.i4, + d.limit, d.offset, + } +} + +func TestCompileTypedSQLTextDoesNotDependOnValues(t *testing.T) { + styles := map[string]typedPlaceholderStyle{ + "numbered": {Prefix: "$", IdentQuote: '"'}, + "question": {IdentQuote: '"'}, + } + for name, style := range styles { + t.Run(name, func(t *testing.T) { + dialect := newFakeTypedDialect() + dialect.style = style + r := rand.New(rand.NewPCG(20261004, 7)) + var reference string + for i := range 300 { + draw := drawTypedValues(r) + text, args, err := compileTypedSQL(typedPropertyQuery(draw), dialect, typedCatalogFacts{}) + if err != nil { + t.Fatalf("draw %d: compileTypedSQL() error = %v", i, err) + } + if i == 0 { + reference = text + } else if text != reference { + t.Fatalf("draw %d changed the SQL text\n got: %s\nwant: %s", i, text, reference) + } + if want := draw.wantArgs(); !reflect.DeepEqual(args, want) { + t.Fatalf("draw %d: args = %#v, want %#v", i, args, want) + } + for _, value := range []string{draw.s1, draw.s2, draw.s3} { + if strings.Contains(text, value) { + t.Fatalf("draw %d: value %q reached the SQL text %q", i, value, text) + } + } + if style.Prefix != "" { + for n := 1; n <= len(args); n++ { + if !strings.Contains(text, fmt.Sprintf("$%d", n)) { + t.Fatalf("draw %d: placeholder $%d is missing from %q", i, n, text) + } + } + if strings.Contains(text, fmt.Sprintf("$%d", len(args)+1)) { + t.Fatalf("draw %d: more placeholders than the %d arguments in %q", i, len(args), text) + } + } + } + }) + } +} + +func TestCompileTypedSQLPropertyQueryShape(t *testing.T) { + // One concrete rendering pins what the property test is quantifying over. + draw := typedDraw{ + i1: 1, i2: 2, i3: 3, i4: 4, f1: 1.5, f2: 2.5, s1: "s1", s2: "s2", s3: "s3", + flag: true, stamp: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC), blob: []byte{9}, limit: 10, offset: 20, + } + typedGolden{ + query: typedPropertyQuery(draw), + wantSQL: `SELECT ("Total" + $1::bigint) AS "bucket", CAST(SUM("Total") AS double precision) AS "total" FROM "Invoice" ` + + `WHERE ("a" = $2::bigint AND "b" > $3::numeric AND "c" = $4 AND "d" = $5::boolean AND "e" <= $6::timestamptz AND "f" = $7::bytea ` + + `AND "g" IN ($8::bigint, $9, $10::numeric) AND "h" NOT IN ($11)) GROUP BY 1 ` + + `HAVING CAST(SUM("Total") AS double precision) > $12::bigint ORDER BY 1 ASC NULLS FIRST LIMIT $13 OFFSET $14`, + wantArgs: draw.wantArgs(), + }.run(t) +} + +func TestCompileTypedSQLNilChangesStructureNotValues(t *testing.T) { + // `== null` is a different statement (IS NULL) rather than a bound NULL; + // that is structure chosen by the caller, not a value leaking into text. + withNil := typedTestFrom("Album", "").NewQuery().Where(typedTestEq(typedTestField("a"), nil)).SelectColumns() + withValue := typedTestFrom("Album", "").NewQuery().Where(typedTestEq(typedTestField("a"), 1)).SelectColumns() + nilText, nilArgs, err := compileTypedSQL(withNil, newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil || nilText != `SELECT * FROM "Album" WHERE "a" IS NULL` || len(nilArgs) != 0 { + t.Fatalf("== nil compiled to %q %v, %v", nilText, nilArgs, err) + } + valueText, _, err := compileTypedSQL(withValue, newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil || valueText == nilText { + t.Fatalf("== 1 compiled to %q, %v", valueText, err) + } +} diff --git a/typed_sql_test.go b/typed_sql_test.go new file mode 100644 index 0000000..bc9d83e --- /dev/null +++ b/typed_sql_test.go @@ -0,0 +1,502 @@ +package dalgo2sql + +import ( + "testing" + "time" + + "github.com/dal-go/dalgo/dal" +) + +func TestCompileTypedSQLSelectList(t *testing.T) { + runTypedGoldens(t, []typedGolden{ + { + name: "no columns selects star", + query: typedTestFrom("Album", "").NewQuery().SelectColumns(), + wantSQL: `SELECT * FROM "Album"`, + }, + { + name: "fields and aliases are quoted", + query: typedTestFrom("Album", "").NewQuery().SelectColumns( + typedTestColumn(typedTestField("Title"), ""), + typedTestColumn(typedTestField("AlbumId"), "id"), + ), + wantSQL: `SELECT "Title", "AlbumId" AS "id" FROM "Album"`, + }, + { + name: "schema and alias", + query: dal.From(dal.NewQualifiedRootCollectionRef("public", "Album", "a")).NewQuery().SelectColumns( + typedTestColumn(typedTestQualified("a", "Title"), ""), + ), + wantSQL: `SELECT "a"."Title" FROM "public"."Album" AS "a"`, + }, + { + name: "pointer collection source", + query: dal.From(typedTestPtr(dal.NewRootCollectionRef("Album", ""))).NewQuery().SelectColumns(typedTestColumn(typedTestField("Title"), "")), + wantSQL: `SELECT "Title" FROM "Album"`, + }, + { + name: "constant with alias is bound", + query: typedTestFrom("Album", "").NewQuery().SelectColumns( + typedTestColumn(typedTestConst(5), "five"), + ), + wantSQL: `SELECT $1::bigint AS "five" FROM "Album"`, + wantArgs: []any{5}, + }, + { + name: "unaliased arithmetic is named by its text", + query: typedTestFrom("Album", "").NewQuery().SelectColumns( + typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestField("b")), ""), + ), + wantSQL: `SELECT ("a" + "b") AS "(a + b)" FROM "Album"`, + }, + }) +} + +func TestCompileTypedSQLWildcardExpansion(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Name: "Album"}: {Columns: []typedColumnFact{ + {Name: "AlbumId"}, {Name: "Title"}, {Name: "Secret"}, {Name: "SecretNote"}, + }}, + }} + runTypedGoldens(t, []typedGolden{ + { + name: "exact exclusion", + facts: facts, + query: typedTestFrom("Album", "").NewQuery().SelectColumns(dal.AllColumnsExcept("Secret", "SecretNote")), + wantSQL: `SELECT "AlbumId", "Title" FROM "Album"`, + }, + { + name: "mask exclusion keeps explicit columns after the wildcard", + facts: facts, + query: typedTestFrom("Album", "").NewQuery().SelectColumns( + dal.AllColumnsExcept("secret*"), + typedTestColumn(typedTestField("Title"), "again"), + ), + wantSQL: `SELECT "AlbumId", "Title", "Title" AS "again" FROM "Album"`, + }, + }) + t.Run("without facts the wildcard is unsupported", func(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery().SelectColumns(dal.AllColumnsExcept("Secret")) + expectTypedUnsupported(t, q, nil, typedCatalogFacts{}, "catalog facts") + }) + t.Run("excluding every column is unsupported", func(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery().SelectColumns(dal.AllColumnsExcept("*")) + expectTypedUnsupported(t, q, nil, facts, "every column") + }) + t.Run("a catalog name the dialect refuses fails the expansion", func(t *testing.T) { + bad := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{{Name: "Album"}: {Columns: []typedColumnFact{{Name: ""}}}}} + q := typedTestFrom("Album", "").NewQuery().SelectColumns(dal.AllColumnsExcept("Secret")) + expectTypedInvalid(t, q, nil, bad, "column 0") + }) + t.Run("a malformed wildcard is unsupported", func(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery().SelectColumns(dal.AllColumnsExcept()) + expectTypedUnsupported(t, q, nil, facts, "at least one exclusion") + }) +} + +func TestCompileTypedSQLFrom(t *testing.T) { + nested := func(onSource string) dal.StructuredQuery { + employees := dal.From(dal.NewQualifiedRootCollectionRef("main", "Employee", "e")) + customers := dal.From(dal.NewQualifiedRootCollectionRef("main", "Customer", "c")).Join( + dal.NewJoinedFrom(employees, dal.JoinLeft, typedTestJoinOn(onSource, "SupportRepId", "e", "EmployeeId")), + ) + return dal.From(dal.NewQualifiedRootCollectionRef("main", "Invoice", "i")).Join( + dal.NewJoinedFrom(customers, dal.JoinInner, typedTestJoinOn("i", "CustomerId", "c", "CustomerId")), + ).NewQuery().SelectColumns( + typedTestColumn(typedTestQualified("i", "InvoiceId"), "invoice_id"), + typedTestColumn(typedTestQualified("e", "FirstName"), "employee"), + ) + } + runTypedGoldens(t, []typedGolden{ + { + name: "inner join", + query: typedTestFrom("Album", "a").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner, typedTestJoinOn("a", "ArtistId", "r", "ArtistId")), + ).NewQuery().SelectColumns( + typedTestColumn(typedTestQualified("a", "Title"), ""), + typedTestColumn(typedTestQualified("r", "Name"), "artist"), + ), + wantSQL: `SELECT "a"."Title", "r"."Name" AS "artist" FROM "Album" AS "a" INNER JOIN "Artist" AS "r" ON ("a"."ArtistId" = "r"."ArtistId")`, + }, + { + name: "left join with two key pairs and a default join type", + query: typedTestFrom("Album", "a").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinLeft, + typedTestJoinOn("a", "ArtistId", "r", "ArtistId"), typedTestJoinOn("a", "Region", "r", "Region")), + ).Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Genre", "g"), "", typedTestJoinOn("a", "GenreId", "g", "GenreId")), + ).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("g", "Name"), "")), + wantSQL: `SELECT "g"."Name" FROM "Album" AS "a" LEFT JOIN "Artist" AS "r" ON ("a"."ArtistId" = "r"."ArtistId" AND "a"."Region" = "r"."Region") INNER JOIN "Genre" AS "g" ON ("a"."GenreId" = "g"."GenreId")`, + }, + { + name: "nested join keeps its parentheses", + query: nested("c"), + wantSQL: `SELECT "i"."InvoiceId" AS "invoice_id", "e"."FirstName" AS "employee" FROM "main"."Invoice" AS "i" ` + + `INNER JOIN ("main"."Customer" AS "c" LEFT JOIN "main"."Employee" AS "e" ON ("c"."SupportRepId" = "e"."EmployeeId")) ` + + `ON ("i"."CustomerId" = "c"."CustomerId")`, + }, + }) + t.Run("a nested ON naming an outer alias is refused", func(t *testing.T) { + expectTypedUnsupported(t, nested("i"), nil, typedCatalogFacts{}, "outside its subtree") + }) +} + +func TestCompileTypedSQLWhere(t *testing.T) { + stamp := time.Date(2026, 10, 4, 12, 30, 0, 0, time.UTC) + hostile := `O'Brien'; DROP TABLE x; --` + album := func(conditions ...dal.Condition) dal.StructuredQuery { + return typedTestFrom("Album", "").NewQuery().Where(conditions...).SelectColumns(typedTestColumn(typedTestField("Title"), "")) + } + const head = `SELECT "Title" FROM "Album" WHERE ` + runTypedGoldens(t, []typedGolden{ + {name: "equal", query: album(typedTestEq(typedTestField("AlbumId"), 5)), wantSQL: head + `"AlbumId" = $1::bigint`, wantArgs: []any{5}}, + {name: "greater", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.GreaterThen, typedTestConst(5))), wantSQL: head + `"AlbumId" > $1::bigint`, wantArgs: []any{5}}, + {name: "greater or equal", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.GreaterOrEqual, typedTestConst(5))), wantSQL: head + `"AlbumId" >= $1::bigint`, wantArgs: []any{5}}, + {name: "less", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.LessThen, typedTestConst(5))), wantSQL: head + `"AlbumId" < $1::bigint`, wantArgs: []any{5}}, + {name: "less or equal", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.LessOrEqual, typedTestConst(5))), wantSQL: head + `"AlbumId" <= $1::bigint`, wantArgs: []any{5}}, + {name: "fraction", query: album(typedTestEq(typedTestField("Price"), 9.99)), wantSQL: head + `"Price" = $1::numeric`, wantArgs: []any{9.99}}, + {name: "bool", query: album(typedTestEq(typedTestField("Active"), true)), wantSQL: head + `"Active" = $1::boolean`, wantArgs: []any{true}}, + {name: "time", query: album(typedTestEq(typedTestField("Released"), stamp)), wantSQL: head + `"Released" = $1::timestamptz`, wantArgs: []any{stamp}}, + {name: "bytes", query: album(typedTestEq(typedTestField("Blob"), []byte{1, 2})), wantSQL: head + `"Blob" = $1::bytea`, wantArgs: []any{[]byte{1, 2}}}, + {name: "text is untyped so dates and uuids work", query: album(typedTestEq(typedTestField("Title"), hostile)), wantSQL: head + `"Title" = $1`, wantArgs: []any{hostile}}, + {name: "equal null is IS NULL", query: album(typedTestEq(typedTestField("Title"), nil)), wantSQL: head + `"Title" IS NULL`}, + {name: "ordering against null stays unknown", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.GreaterThen, typedTestConst(nil))), wantSQL: head + `"AlbumId" > $1`, wantArgs: []any{nil}}, + {name: "field against field", query: album(dal.NewComparison(typedTestField("A"), dal.LessThen, typedTestField("B"))), wantSQL: head + `"A" < "B"`}, + {name: "is null", query: album(dal.NewIsNullCondition(typedTestField("Title"))), wantSQL: head + `"Title" IS NULL`}, + {name: "is not null", query: album(dal.NewIsNotNullCondition(typedTestField("Title"))), wantSQL: head + `"Title" IS NOT NULL`}, + { + name: "arithmetic on the left", + query: album(dal.NewComparison(dal.Binary(typedTestField("A"), dal.Add, typedTestConst(1)), dal.GreaterThen, typedTestConst(10))), + wantSQL: head + `("A" + $1::bigint) > $2::bigint`, + wantArgs: []any{1, 10}, + }, + { + name: "is null of arithmetic", + query: album(dal.NewIsNullCondition(dal.Binary(typedTestField("A"), dal.Multiply, typedTestConst(2)))), + wantSQL: head + `("A" * $1::bigint) IS NULL`, + wantArgs: []any{2}, + }, + { + name: "nested groups keep order and numbering", + query: album(dal.NewGroupCondition(dal.And, typedTestEq(typedTestField("A"), 1), dal.NewGroupCondition(dal.Or, dal.NewComparison(typedTestField("B"), dal.GreaterThen, typedTestConst(2)), dal.NewIsNullCondition(typedTestField("C"))))), + wantSQL: head + `("A" = $1::bigint AND ("B" > $2::bigint OR "C" IS NULL))`, + wantArgs: []any{1, 2}, + }, + { + name: "several conditions are ANDed by the builder", + query: album(typedTestEq(typedTestField("A"), 1), typedTestEq(typedTestField("B"), "x")), + wantSQL: head + `("A" = $1::bigint AND "B" = $2)`, + wantArgs: []any{1, "x"}, + }, + { + name: "IN", + query: album(dal.NewComparison(typedTestField("AlbumId"), dal.In, dal.NewArray([]int{1, 2, 3}))), + wantSQL: head + `"AlbumId" IN ($1::bigint, $2::bigint, $3::bigint)`, + wantArgs: []any{1, 2, 3}, + }, + { + name: "NOT IN keeps three-valued logic by binding NULL", + query: album(dal.NewComparison(typedTestField("AlbumId"), dal.NotIn, dal.NewArray([]any{1, nil, "x"}))), + wantSQL: head + `"AlbumId" NOT IN ($1::bigint, $2, $3)`, + wantArgs: []any{1, nil, "x"}, + }, + { + name: "IN with arithmetic on the left", + query: album(dal.NewComparison(dal.Binary(typedTestField("A"), dal.Subtract, typedTestConst(1)), dal.In, dal.NewArray([]string{"p"}))), + wantSQL: head + `("A" - $1::bigint) IN ($2)`, + wantArgs: []any{1, "p"}, + }, + {name: "empty IN is false", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.In, dal.NewArray([]int{}))), wantSQL: head + `FALSE`}, + {name: "empty NOT IN is true", query: album(dal.NewComparison(typedTestField("AlbumId"), dal.NotIn, dal.NewArray([]int{}))), wantSQL: head + `TRUE`}, + { + name: "question style keeps the neutral marker", + dialect: &fakeTypedDialect{style: typedPlaceholderStyle{IdentQuote: '"'}, caps: fullFakeTypedCapabilities(), identLimit: 63}, + query: album(typedTestEq(typedTestField("A"), 1), typedTestEq(typedTestField("B"), 2)), + wantSQL: `SELECT "Title" FROM "Album" WHERE ("A" = ?::bigint AND "B" = ?::bigint)`, + wantArgs: []any{1, 2}, + }, + }) +} + +func TestCompileTypedSQLAggregates(t *testing.T) { + invoice := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + count := typedTestColumn(dal.NewAggregate(dal.COUNT, false, dal.Star()), "") + runTypedGoldens(t, []typedGolden{ + { + name: "every native aggregate over one group", + query: invoice().SelectColumns( + count, + dal.CountAs(typedTestField("A"), "c"), + dal.CountDistinctAs(typedTestField("A"), "cd"), + dal.SumAs(typedTestField("B"), "s"), + dal.SumDistinctAs(typedTestField("B"), "sd"), + dal.AverageAs(typedTestField("B"), "a"), + dal.AverageDistinctAs(typedTestField("B"), "ad"), + dal.MinAs(typedTestField("C"), "mn"), + dal.MaxAs(typedTestField("C"), "mx"), + ), + wantSQL: `SELECT COUNT(*) AS "COUNT(*)", COUNT("A") AS "c", COUNT(DISTINCT "A") AS "cd", ` + + `CAST(SUM("B") AS double precision) AS "s", CAST(SUM(DISTINCT "B") AS double precision) AS "sd", ` + + `CAST(AVG("B") AS double precision) AS "a", CAST(AVG(DISTINCT "B") AS double precision) AS "ad", ` + + `MIN("C") AS "mn", MAX("C") AS "mx" FROM "Invoice"`, + }, + { + name: "group by having order by limit offset", + query: invoice(). + GroupBy(typedTestField("BillingCountry")). + Having(dal.NewComparison(dal.NewAggregate(dal.COUNT, false, dal.Star()), dal.GreaterThen, typedTestConst(5))). + OrderBy(dal.Descending(typedTestField("total")), dal.Ascending(typedTestField("BillingCountry"))). + Limit(10).Offset(20). + SelectColumns(typedTestColumn(typedTestField("BillingCountry"), ""), dal.SumAs(typedTestField("Total"), "total")), + wantSQL: `SELECT "BillingCountry", CAST(SUM("Total") AS double precision) AS "total" FROM "Invoice" ` + + `GROUP BY "BillingCountry" HAVING COUNT(*) > $1::bigint ` + + `ORDER BY CAST(SUM("Total") AS double precision) DESC NULLS LAST, "BillingCountry" ASC NULLS FIRST LIMIT $2 OFFSET $3`, + wantArgs: []any{5, 10, 20}, + }, + { + name: "group by without columns selects the group keys", + query: invoice().GroupBy(typedTestField("BillingCountry"), typedTestField("BillingCity")).SelectColumns(), + wantSQL: `SELECT "BillingCountry", "BillingCity" FROM "Invoice" GROUP BY "BillingCountry", "BillingCity"`, + }, + { + name: "having aliases are rewritten to expressions", + query: invoice(). + GroupBy(typedTestField("BillingCountry")). + Having(dal.NewComparison(typedTestField("total"), dal.GreaterOrEqual, typedTestConst(100.5)), dal.NewIsNotNullCondition(typedTestField("total"))). + SelectColumns(typedTestColumn(typedTestField("BillingCountry"), ""), dal.SumAs(typedTestField("Total"), "total")), + wantSQL: `SELECT "BillingCountry", CAST(SUM("Total") AS double precision) AS "total" FROM "Invoice" GROUP BY "BillingCountry" ` + + `HAVING (CAST(SUM("Total") AS double precision) >= $1::numeric AND CAST(SUM("Total") AS double precision) IS NOT NULL)`, + wantArgs: []any{100.5}, + }, + { + name: "having over a nested group of aliases and arithmetic", + query: invoice(). + GroupBy(typedTestField("BillingCountry")). + Having(dal.NewGroupCondition(dal.Or, + dal.NewComparison(dal.Binary(typedTestField("total"), dal.Add, typedTestConst(1)), dal.GreaterThen, typedTestConst(2)), + dal.NewIsNullCondition(typedTestField("total")))). + SelectColumns(typedTestColumn(typedTestField("BillingCountry"), ""), dal.SumAs(typedTestField("Total"), "total")), + wantSQL: `SELECT "BillingCountry", CAST(SUM("Total") AS double precision) AS "total" FROM "Invoice" GROUP BY "BillingCountry" ` + + `HAVING ((CAST(SUM("Total") AS double precision) + $1::bigint) > $2::bigint OR CAST(SUM("Total") AS double precision) IS NULL)`, + wantArgs: []any{1, 2}, + }, + { + name: "a grouped expression carrying a constant is referenced by ordinal", + query: invoice(). + GroupBy(dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1))). + OrderBy(dal.Ascending(typedTestField("bucket"))). + SelectColumns(typedTestColumn(dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1)), "bucket"), typedTestColumn(dal.NewAggregate(dal.COUNT, false, dal.Star()), "n")), + wantSQL: `SELECT ("Total" + $1::bigint) AS "bucket", COUNT(*) AS "n" FROM "Invoice" GROUP BY 1 ORDER BY 1 ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "an unselected grouped expression carrying a constant stays inline", + query: invoice(). + GroupBy(dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1))). + SelectColumns(typedTestColumn(dal.NewAggregate(dal.COUNT, false, dal.Star()), "n")), + wantSQL: `SELECT COUNT(*) AS "n" FROM "Invoice" GROUP BY ("Total" + $1::bigint)`, + wantArgs: []any{1}, + }, + { + name: "a grouped field without constants stays inline in order by", + query: invoice(). + GroupBy(typedTestField("Country")). + OrderBy(dal.Descending(typedTestField("Country"))). + SelectColumns(typedTestColumn(typedTestField("Country"), "c"), dal.CountAs(typedTestField("Id"), "n")), + wantSQL: `SELECT "Country" AS "c", COUNT("Id") AS "n" FROM "Invoice" GROUP BY "Country" ORDER BY "Country" DESC NULLS LAST`, + }, + { + name: "having over a grouped alias without constants is rewritten", + query: invoice(). + GroupBy(typedTestField("Country")). + Having(dal.NewComparison(typedTestField("c"), dal.Equal, typedTestConst("FR"))). + SelectColumns(typedTestColumn(typedTestField("Country"), "c"), dal.CountAs(typedTestField("Id"), "n")), + wantSQL: `SELECT "Country" AS "c", COUNT("Id") AS "n" FROM "Invoice" GROUP BY "Country" HAVING "Country" = $1`, + wantArgs: []any{"FR"}, + }, + { + name: "having over an aggregate alias that carries a constant is fine", + query: invoice(). + Having(dal.NewComparison(typedTestField("total"), dal.GreaterThen, typedTestConst(1))). + SelectColumns(typedTestColumn(dal.Binary(dal.NewAggregate(dal.SUM, false, typedTestField("Total")), dal.Add, typedTestConst(1)), "total")), + wantSQL: `SELECT (CAST(SUM("Total") AS double precision) + $1::bigint) AS "total" FROM "Invoice" ` + + `HAVING (CAST(SUM("Total") AS double precision) + $2::bigint) > $3::bigint`, + wantArgs: []any{1, 1, 1}, + }, + }) +} + +func TestCompileTypedSQLOrderBy(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Name: "Album"}: {Columns: []typedColumnFact{{Name: "AlbumId", NotNull: true}, {Name: "Title"}}}, + {Name: "Artist"}: {Columns: []typedColumnFact{{Name: "ArtistId", NotNull: true}, {Name: "Name"}}}, + }} + album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } + runTypedGoldens(t, []typedGolden{ + { + name: "NULLS clause matches DALgo when nothing is known", + query: album().OrderBy(dal.Ascending(typedTestField("AlbumId")), dal.Descending(typedTestField("Title"))).SelectColumns(), + wantSQL: `SELECT * FROM "Album" ORDER BY "AlbumId" ASC NULLS FIRST, "Title" DESC NULLS LAST`, + }, + { + name: "NOT NULL columns omit the NULLS clause so an index can serve the order", + facts: facts, + query: album().OrderBy(dal.Ascending(typedTestField("AlbumId")), dal.Descending(typedTestField("AlbumId")), dal.Descending(typedTestField("Title"))).SelectColumns(), + wantSQL: `SELECT * FROM "Album" ORDER BY "AlbumId" ASC, "AlbumId" DESC, "Title" DESC NULLS LAST`, + }, + { + name: "a qualified NOT NULL column on the preserved side of a left join", + facts: facts, + query: typedTestFrom("Album", "a").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinLeft, typedTestJoinOn("a", "ArtistId", "r", "ArtistId")), + ).NewQuery().OrderBy(dal.Ascending(typedTestQualified("a", "AlbumId")), dal.Ascending(typedTestQualified("r", "ArtistId"))).SelectColumns(), + wantSQL: `SELECT * FROM "Album" AS "a" LEFT JOIN "Artist" AS "r" ON ("a"."ArtistId" = "r"."ArtistId") ORDER BY "a"."AlbumId" ASC, "r"."ArtistId" ASC NULLS FIRST`, + }, + { + name: "nullability propagates through a nested left join", + facts: facts, + query: func() dal.StructuredQuery { + inner := dal.From(dal.NewRootCollectionRef("Artist", "r")) + return typedTestFrom("Album", "a").Join( + dal.NewJoinedFrom(inner, dal.JoinLeft, typedTestJoinOn("a", "ArtistId", "r", "ArtistId")), + ).NewQuery().OrderBy(dal.Ascending(typedTestQualified("r", "ArtistId"))).SelectColumns() + }(), + wantSQL: `SELECT * FROM "Album" AS "a" LEFT JOIN "Artist" AS "r" ON ("a"."ArtistId" = "r"."ArtistId") ORDER BY "r"."ArtistId" ASC NULLS FIRST`, + }, + { + name: "an inner join below a left join is nullable too", + facts: facts, + query: func() dal.StructuredQuery { + artists := dal.From(dal.NewRootCollectionRef("Artist", "r")).Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Album", "b"), dal.JoinInner, typedTestJoinOn("r", "ArtistId", "b", "ArtistId")), + ) + return typedTestFrom("Album", "a").Join( + dal.NewJoinedFrom(artists, dal.JoinLeft, typedTestJoinOn("a", "ArtistId", "r", "ArtistId")), + ).NewQuery().OrderBy(dal.Ascending(typedTestQualified("b", "AlbumId")), dal.Ascending(typedTestQualified("a", "AlbumId"))).SelectColumns() + }(), + wantSQL: `SELECT * FROM "Album" AS "a" LEFT JOIN ("Artist" AS "r" INNER JOIN "Album" AS "b" ON ("r"."ArtistId" = "b"."ArtistId")) ON ("a"."ArtistId" = "r"."ArtistId") ` + + `ORDER BY "b"."AlbumId" ASC NULLS FIRST, "a"."AlbumId" ASC`, + }, + { + name: "an aggregate is never assumed NOT NULL", + facts: facts, + query: album().OrderBy(dal.Ascending(dal.NewAggregate(dal.MIN, false, typedTestField("AlbumId")))). + SelectColumns(typedTestColumn(dal.NewAggregate(dal.MIN, false, typedTestField("AlbumId")), "m")), + wantSQL: `SELECT MIN("AlbumId") AS "m" FROM "Album" ORDER BY MIN("AlbumId") ASC NULLS FIRST`, + }, + { + name: "an unknown source falls back to the NULLS clause", + facts: facts, + query: typedTestFrom("Genre", "").NewQuery().OrderBy(dal.Ascending(typedTestField("GenreId"))).SelectColumns(), + wantSQL: `SELECT * FROM "Genre" ORDER BY "GenreId" ASC NULLS FIRST`, + }, + { + name: "an alias selecting a constant-carrying expression is ordered by ordinal", + query: album().OrderBy(dal.Descending(typedTestField("shifted"))).SelectColumns(typedTestColumn(typedTestField("Title"), ""), typedTestColumn(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)), "shifted")), + wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY 2 DESC NULLS LAST`, + wantArgs: []any{1}, + }, + { + name: "a selected expression written out again is ordered by ordinal", + query: album().OrderBy(dal.Ascending(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)))). + SelectColumns(typedTestColumn(typedTestField("Title"), ""), typedTestColumn(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)), "shifted")), + wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY 2 ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "an unselected expression carrying a constant is bound inline", + query: album().OrderBy(dal.Ascending(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)))).SelectColumns(typedTestColumn(typedTestField("Title"), "")), + wantSQL: `SELECT "Title" FROM "Album" ORDER BY ("AlbumId" + $1::bigint) ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "an expanded wildcard shifts the ordinal", + facts: typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{{Name: "Album"}: {Columns: []typedColumnFact{{Name: "A"}, {Name: "B"}, {Name: "X"}}}}}, + query: album().OrderBy(dal.Ascending(typedTestField("n"))).SelectColumns(dal.AllColumnsExcept("X"), typedTestColumn(dal.Binary(typedTestField("A"), dal.Add, typedTestConst(1)), "n")), + wantSQL: `SELECT "A", "B", ("A" + $1::bigint) AS "n" FROM "Album" ORDER BY 3 ASC NULLS FIRST`, wantArgs: []any{1}, + }, + }) +} + +func TestCompileTypedSQLArithmetic(t *testing.T) { + album := func(expression dal.Expression) dal.StructuredQuery { + return typedTestFrom("Album", "").NewQuery().SelectColumns(typedTestColumn(expression, "r")) + } + runTypedGoldens(t, []typedGolden{ + {name: "add", query: album(dal.Binary(typedTestField("a"), dal.Add, typedTestField("b"))), wantSQL: `SELECT ("a" + "b") AS "r" FROM "Album"`}, + {name: "subtract", query: album(dal.Binary(typedTestField("a"), dal.Subtract, typedTestConst(2))), wantSQL: `SELECT ("a" - $1::bigint) AS "r" FROM "Album"`, wantArgs: []any{2}}, + {name: "multiply", query: album(dal.Binary(typedTestConst(2.5), dal.Multiply, typedTestField("b"))), wantSQL: `SELECT ($1::numeric * "b") AS "r" FROM "Album"`, wantArgs: []any{2.5}}, + { + name: "divide is the dialect's business", + query: album(dal.Binary(typedTestField("a"), dal.Divide, typedTestField("b"))), + wantSQL: `SELECT (CAST("a" AS double precision) / NULLIF(CAST("b" AS double precision), 0)) AS "r" FROM "Album"`, + }, + { + name: "nested arithmetic keeps argument order", + query: album(dal.Binary(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, dal.Binary(typedTestField("b"), dal.Subtract, typedTestConst(2)))), + wantSQL: `SELECT (CAST(("a" + $1::bigint) AS double precision) / NULLIF(CAST(("b" - $2::bigint) AS double precision), 0)) AS "r" FROM "Album"`, + wantArgs: []any{1, 2}, + }, + }) +} + +func TestCompileTypedSQLLimitAndOffset(t *testing.T) { + album := func(limit, offset int) dal.StructuredQuery { + return typedTestFrom("Album", "").NewQuery().Limit(limit).Offset(offset).SelectColumns() + } + runTypedGoldens(t, []typedGolden{ + {name: "none", query: album(0, 0), wantSQL: `SELECT * FROM "Album"`}, + {name: "limit", query: album(10, 0), wantSQL: `SELECT * FROM "Album" LIMIT $1`, wantArgs: []any{10}}, + {name: "offset only", query: album(0, 5), wantSQL: `SELECT * FROM "Album" OFFSET $1`, wantArgs: []any{5}}, + {name: "both", query: album(10, 5), wantSQL: `SELECT * FROM "Album" LIMIT $1 OFFSET $2`, wantArgs: []any{10, 5}}, + }) +} + +func TestCompileTypedSQLNumberingFollowsTextOrderAcrossClauses(t *testing.T) { + q := typedTestFrom("Invoice", "").NewQuery(). + Where(typedTestEq(typedTestField("a"), 1)). + GroupBy(typedTestField("g")). + Having(dal.NewComparison(dal.NewAggregate(dal.SUM, false, typedTestField("t")), dal.GreaterThen, typedTestConst(2))). + OrderBy(dal.Ascending(dal.Binary(typedTestField("g"), dal.Add, typedTestConst(3)))). + Limit(4).Offset(5). + SelectColumns(typedTestColumn(typedTestField("g"), ""), typedTestColumn(dal.Binary(dal.Count().Expression, dal.Add, typedTestConst(6)), "n")) + typedGolden{ + query: q, + wantSQL: `SELECT "g", (COUNT(*) + $1::bigint) AS "n" FROM "Invoice" WHERE "a" = $2::bigint GROUP BY "g" ` + + `HAVING CAST(SUM("t") AS double precision) > $3::bigint ORDER BY ("g" + $4::bigint) ASC NULLS FIRST LIMIT $5 OFFSET $6`, + wantArgs: []any{6, 1, 2, 3, 4, 5}, + }.run(t) +} + +func TestCompileTypedSQLIdentifiersWithPlaceholderAndQuoteCharacters(t *testing.T) { + weird := `we?ird"col` + q := typedTestFrom(`tab?le"x`, "").NewQuery(). + Where(dal.NewGroupCondition(dal.And, typedTestEq(typedTestField(weird), 1), typedTestEq(typedTestField(`?`), 2), typedTestEq(typedTestField(`""`), 3))). + OrderBy(dal.Ascending(typedTestField(weird))). + SelectColumns(typedTestColumn(typedTestField(weird), `a?"b`)) + typedGolden{ + query: q, + wantSQL: `SELECT "we?ird""col" AS "a?""b" FROM "tab?le""x" WHERE ("we?ird""col" = $1::bigint AND "?" = $2::bigint AND """""" = $3::bigint) ` + + `ORDER BY "we?ird""col" ASC NULLS FIRST`, + wantArgs: []any{1, 2, 3}, + }.run(t) +} + +func TestCompileTypedSQLHostileValueNeverReachesTheText(t *testing.T) { + hostile := `x'); DROP TABLE "Album"; -- $1 ?` + q := typedTestFrom("Album", "").NewQuery(). + Where(typedTestEq(typedTestField("Title"), hostile)). + SelectColumns(typedTestColumn(typedTestField("Title"), "")) + text, args, err := compileTypedSQL(q, newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil { + t.Fatal(err) + } + if want := `SELECT "Title" FROM "Album" WHERE "Title" = $1`; text != want { + t.Fatalf("SQL = %q, want %q", text, want) + } + if len(args) != 1 || args[0] != hostile { + t.Fatalf("args = %#v, want the hostile value as the only argument", args) + } +} + +func typedTestPtr[T any](value T) *T { return &value } From 02015cf57b99443f8d72da72b9d3c5b504966526 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 21:33:11 +0100 Subject: [PATCH 2/6] fix(typed-sql): refuse what a join cannot honour and enforce the dialect contract Review round 1 of the typed SQL compiler. - A scan-bounded or named-database source inside a join is refused with ErrNotSupported instead of being rendered as the whole local table; a lone source keeps working because the generic join restates its scan as ORDER BY and LIMIT. - ORDER BY over a grouped, constant-carrying alias nested in a larger expression is refused as HAVING already refuses it (one alias rewriter per ORDER BY item; only for statements with a GROUP BY). - dal.DocumentID() is refused until it is mapped to the primary key; dal.ID keeps naming a real column. - The argument limit is checked per statement, not per IN list. - Ordinal references compare expressions structurally with constants of the same kind and content, not by String(). - The dialect contract states the operand count/order rule and the no-literal/no-comment rule, and the compiler enforces both on every fragment; the numbering pass refuses string literals and comments. - compileTypedSQL documents exactly what the text depends on. - The value-independence proof is a helper that takes a typedDialect; a generated test covers two independently drawn constants. - Goldens for the journey statement, an aggregate argument with a constant and quote characters in qualifiers. Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 32 ++++- typed_dialect_fake_test.go | 28 +++- typed_placeholders.go | 74 ++++++++++- typed_placeholders_test.go | 86 ++++++++++++ typed_sql.go | 231 +++++++++++++++++++++++++++++--- typed_sql_equality_test.go | 171 ++++++++++++++++++++++++ typed_sql_errors_test.go | 266 +++++++++++++++++++++++++++++++++++++ typed_sql_property_test.go | 197 +++++++++++++++++++++------ typed_sql_test.go | 150 +++++++++++++++++++++ 9 files changed, 1171 insertions(+), 64 deletions(-) create mode 100644 typed_sql_equality_test.go diff --git a/typed_dialect.go b/typed_dialect.go index 252aa12..3243791 100644 --- a/typed_dialect.go +++ b/typed_dialect.go @@ -16,13 +16,37 @@ import ( // (PostgreSQL first; MySQL, SQL Server and Oracle later). compileTypedSQL owns // the walk over the query; the dialect owns every engine-specific word. // -// The contract that keeps the compiler safe: +// The contract that keeps the compiler safe. The compiler checks every rule +// marked (checked) on each fragment a dialect returns and refuses the statement +// when one is broken, so a faulty dialect fails loudly instead of binding a +// value to the wrong place. // -// - Nothing a dialect returns may depend on a value. Values travel only as -// bound arguments, so the SQL text is the same whatever the caller's -// constants are. +// - Nothing a dialect returns may depend on the content of a value. Values +// travel only as bound arguments, so the SQL text is the same whatever the +// caller's constants are. bind may type its marker by the Go type of the +// value (typedKindOf), never by what the value holds: not "bigint for a +// whole number and numeric for a fraction" read from the number, but one cast +// per type. assertTypedTextIgnoresValues (typed_sql_property_test.go) takes +// a dialect and is the test every dialect must pass. // - Every placeholder a dialect writes is the neutral marker "?". The // compiler numbers the markers once, at the end, with placeholderStyle. +// bind and limitOffset write exactly one marker per argument they return; +// every other method writes none of its own (checked). +// - Operand rule 1, count and order (checked): divide, aggregateResult and +// orderItem receive operands already rendered as SQL text. The compiler +// appends the operands' arguments in the order it passes them, and the +// numbering pass gives the nth marker of the final text the nth argument. +// So an operand that carries a marker must be written verbatim, exactly +// once, and the operands in the order they were passed. A dialect that +// writes divide's right operand before its left, or repeats one, keeps the +// marker count right and binds values to the wrong positions. An operand +// without a marker carries no argument and may be repeated, moved or +// wrapped freely. +// - Operand rule 2, no literal and no comment (checked): a fragment is +// quoted identifiers, keywords, punctuation and the marker, never a string +// literal and never a comment. The numbering pass understands quoted +// identifiers only, so a "?" inside a literal or a comment would be +// numbered as a parameter. Write constants with bind. // - Fragments that stand for an expression (divide, aggregateResult) must be // self-delimiting, that is wrapped in parentheses or a function call, so // the compiler can place them next to any operator. diff --git a/typed_dialect_fake_test.go b/typed_dialect_fake_test.go index 4e8718e..f8823cb 100644 --- a/typed_dialect_fake_test.go +++ b/typed_dialect_fake_test.go @@ -18,9 +18,16 @@ type fakeTypedDialect struct { style typedPlaceholderStyle caps dal.QueryCapabilities identLimit int - // quoteOverride and bindMarkers let a test make the dialect misbehave. - quoteOverride func(string) (string, error) - bindMarkers string + // quoteOverride, bindMarkers and the *Override hooks let a test make the + // dialect misbehave, to prove the compiler refuses a dialect that breaks the + // contract in typed_dialect.go. + quoteOverride func(string) (string, error) + bindMarkers string + divideOverride func(left, right string) string + aggregateOverride func(function, aggregate string) string + orderItemOverride func(expression string, descending, notNull bool) string + emptyInOverride func(negated bool) string + limitOffsetOverride func(limit, offset int) (string, []any) } func newFakeTypedDialect() *fakeTypedDialect { @@ -93,6 +100,9 @@ func (d *fakeTypedDialect) bind(value any) (string, any, error) { } func (d *fakeTypedDialect) limitOffset(limit, offset int) (string, []any) { + if d.limitOffsetOverride != nil { + return d.limitOffsetOverride(limit, offset) + } switch { case limit > 0 && offset > 0: return "LIMIT ? OFFSET ?", []any{limit, offset} @@ -105,6 +115,9 @@ func (d *fakeTypedDialect) limitOffset(limit, offset int) (string, []any) { } func (d *fakeTypedDialect) orderItem(expression string, descending, notNull bool) string { + if d.orderItemOverride != nil { + return d.orderItemOverride(expression, descending, notNull) + } switch { case notNull && descending: return expression + " DESC" @@ -117,10 +130,16 @@ func (d *fakeTypedDialect) orderItem(expression string, descending, notNull bool } func (d *fakeTypedDialect) divide(left, right string) string { + if d.divideOverride != nil { + return d.divideOverride(left, right) + } return "(CAST(" + left + " AS double precision) / NULLIF(CAST(" + right + " AS double precision), 0))" } func (d *fakeTypedDialect) aggregateResult(function, aggregate string) string { + if d.aggregateOverride != nil { + return d.aggregateOverride(function, aggregate) + } if function == dal.SUM || function == dal.AVERAGE { return "CAST(" + aggregate + " AS double precision)" } @@ -128,6 +147,9 @@ func (d *fakeTypedDialect) aggregateResult(function, aggregate string) string { } func (d *fakeTypedDialect) emptyIn(negated bool) string { + if d.emptyInOverride != nil { + return d.emptyInOverride(negated) + } if negated { return "TRUE" } diff --git a/typed_placeholders.go b/typed_placeholders.go index 84ef08f..3440b72 100644 --- a/typed_placeholders.go +++ b/typed_placeholders.go @@ -1,6 +1,7 @@ package dalgo2sql import ( + "errors" "fmt" "strconv" "strings" @@ -29,6 +30,11 @@ type typedPlaceholderStyle struct { // style, skipping quoted identifiers, and checks that the text carries exactly // one marker per argument: a mismatch means a fragment lost or invented a // parameter, and executing it could bind a value to the wrong position. +// +// It understands quoted identifiers and nothing else, so it also refuses any +// text that holds a string literal or a comment outside one: a marker inside +// either would be numbered, and no value is ever meant to be written into the +// text. A dialect fragment is the only way such text could appear. func numberTypedPlaceholders(text string, style typedPlaceholderStyle, arguments int) (string, error) { var out strings.Builder out.Grow(len(text) + 2*arguments) @@ -39,13 +45,19 @@ func numberTypedPlaceholders(text string, style typedPlaceholderStyle, arguments switch { case b == style.IdentQuote: inIdentifier = !inIdentifier - case b == typedMarker && !inIdentifier: + case inIdentifier: + // Nothing inside a quoted identifier is a marker or a literal. + case b == typedMarker: markers++ if style.Prefix != "" { out.WriteString(style.Prefix) out.WriteString(strconv.Itoa(markers)) continue } + case b == '\'': + return "", errors.New("typed SQL: a fragment holds a string literal; values are bound, never written into the text") + case typedOpensComment(text, i): + return "", errors.New("typed SQL: a fragment holds a comment, which the placeholder scan cannot see through") } out.WriteByte(b) } @@ -57,3 +69,63 @@ func numberTypedPlaceholders(text string, style typedPlaceholderStyle, arguments } return out.String(), nil } + +// typedOpensComment reports whether a line or block comment starts at text[i]. +func typedOpensComment(text string, i int) bool { + if i+1 >= len(text) { + return false + } + pair := text[i : i+2] + return pair == "--" || pair == "/*" +} + +// countTypedMarkers counts the markers of text that stand outside a quoted +// identifier, the same way numberTypedPlaceholders does. +func countTypedMarkers(text string, quote byte) int { + inIdentifier := false + markers := 0 + for i := 0; i < len(text); i++ { + switch b := text[i]; { + case b == quote: + inIdentifier = !inIdentifier + case b == typedMarker && !inIdentifier: + markers++ + } + } + return markers +} + +// checkTypedFragment enforces operand rule 1 of the typedDialect contract on one +// fragment a dialect built around already rendered operands. own is the number +// of markers the dialect itself introduces (bind and limitOffset write one per +// argument they return; every other fragment writes none). +// +// The fragment must hold exactly the markers its operands and own bring, so no +// operand that carries a value is dropped or repeated, and every operand that +// carries a value must appear verbatim, in the order it was passed. The compiler +// appends the operands' arguments in that order and the numbering pass pairs +// the nth marker of the text with the nth argument, so a fragment that swapped +// two operands would keep the count right and bind values to the wrong places. +// An operand without a marker carries no argument, so it may be repeated, moved +// or rewritten freely. +func checkTypedFragment(fragment string, quote byte, own int, operands ...string) error { + want := own + for _, operand := range operands { + want += countTypedMarkers(operand, quote) + } + if got := countTypedMarkers(fragment, quote); got != want { + return fmt.Errorf("typed SQL: the dialect wrote %d placeholders where %d belong", got, want) + } + rest := fragment + for _, operand := range operands { + if countTypedMarkers(operand, quote) == 0 { + continue + } + at := strings.Index(rest, operand) + if at < 0 { + return errors.New("typed SQL: the dialect did not write an operand carrying a value verbatim and in argument order") + } + rest = rest[at+len(operand):] + } + return nil +} diff --git a/typed_placeholders_test.go b/typed_placeholders_test.go index b64c3ec..d4865cc 100644 --- a/typed_placeholders_test.go +++ b/typed_placeholders_test.go @@ -48,6 +48,11 @@ func TestNumberTypedPlaceholdersRejectsInconsistentText(t *testing.T) { {"fewer markers than arguments", `a = ?`, 2, "1 placeholders for 2 arguments"}, {"an identifier that never closes", `"a = ?`, 1, "quoted identifier"}, {"an identifier that never closes hides its marker", `x = ? AND "a = ?`, 2, "quoted identifier"}, + {"a string literal", `a = 'x' AND b = ?`, 1, "string literal"}, + {"a string literal hiding a marker", `a = '?' AND b = ?`, 2, "string literal"}, + {"a line comment", `a = ? -- x`, 1, "comment"}, + {"a block comment", `a = ? /* x */`, 1, "comment"}, + {"a block comment that opens the text", `/* x */ a = ?`, 1, "comment"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -58,3 +63,84 @@ func TestNumberTypedPlaceholdersRejectsInconsistentText(t *testing.T) { }) } } + +func TestNumberTypedPlaceholdersAcceptsLookalikesInsideIdentifiersAndOperators(t *testing.T) { + dollar := typedPlaceholderStyle{Prefix: "$", IdentQuote: '"'} + cases := []struct { + name, text string + args int + want string + }{ + {"quote, dashes and comment openers inside an identifier", `"it's" = ? AND "a--b" = ? AND "c/*d" = ?`, 3, `"it's" = $1 AND "a--b" = $2 AND "c/*d" = $3`}, + {"a lone minus and a lone slash", `(a - ?) / (b / ?)`, 2, `(a - $1) / (b / $2)`}, + {"a star after a parenthesis", `COUNT(*) - ?`, 1, `COUNT(*) - $1`}, + {"a dash at the very end", `a -`, 0, `a -`}, + {"a slash at the very end", `a /`, 0, `a /`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := numberTypedPlaceholders(tc.text, dollar, tc.args) + if err != nil || got != tc.want { + t.Fatalf("numberTypedPlaceholders() = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestCountTypedMarkers(t *testing.T) { + cases := []struct { + name, text string + want int + }{ + {"none", `"a" + "b"`, 0}, + {"two", `? + ?::bigint`, 2}, + {"inside an identifier is not a marker", `"a?b" = ?`, 1}, + {"a doubled quote keeps the identifier open", `"a""?" = ? AND ? = 1`, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := countTypedMarkers(tc.text, '"'); got != tc.want { + t.Fatalf("countTypedMarkers(%q) = %d, want %d", tc.text, got, tc.want) + } + }) + } +} + +func TestCheckTypedFragment(t *testing.T) { + left, right := `("a" + ?::bigint)`, `("b" - ?::bigint)` + valueFree, other := `"a"`, `"b"` + cases := []struct { + name string + fragment string + own int + operands []string + wantError string + }{ + {"operands in order", "(" + left + " / " + right + ")", 0, []string{left, right}, ""}, + {"operands wrapped in order", "NULLIF(" + left + ", 0) / CAST(" + right + " AS double precision)", 0, []string{left, right}, ""}, + {"operands in the wrong order", "(" + right + " / " + left + ")", 0, []string{left, right}, "verbatim and in argument order"}, + {"an operand renders twice", "(" + left + " / " + left + ")", 0, []string{left}, "2 placeholders where 1 belong"}, + {"an operand is dropped", "(" + left + ")", 0, []string{left, right}, "1 placeholders where 2 belong"}, + {"an operand is rewritten", "(" + strings.ToUpper(left) + " / " + right + ")", 0, []string{left, right}, "verbatim and in argument order"}, + {"the first operand is missing but the count is made up", "(" + right + " / " + right + ")", 0, []string{left, right}, "verbatim and in argument order"}, + {"value-free operands may repeat and reorder", valueFree + " / " + other + " / " + valueFree, 0, []string{valueFree, other}, ""}, + {"the dialect's own marker is counted", "?::bigint", 1, nil, ""}, + {"two own markers where one belongs", "? + ?", 1, nil, "2 placeholders where 1 belong"}, + {"no marker where the dialect owes one", "TRUE", 1, nil, "0 placeholders where 1 belong"}, + {"an empty fragment owes nothing", "", 0, nil, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := checkTypedFragment(tc.fragment, '"', tc.own, tc.operands...) + if tc.wantError == "" { + if err != nil { + t.Fatalf("checkTypedFragment() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("checkTypedFragment() = %v, want an error mentioning %q", err, tc.wantError) + } + }) + } +} diff --git a/typed_sql.go b/typed_sql.go index 92c5f36..38b19af 100644 --- a/typed_sql.go +++ b/typed_sql.go @@ -1,19 +1,23 @@ package dalgo2sql import ( + "bytes" "errors" "fmt" "maps" + "math" "reflect" "slices" "strconv" "strings" + "time" "github.com/dal-go/dalgo/dal" ) -// typedMaxArguments bounds the values one statement may bind. PostgreSQL's wire -// protocol counts parameters in 16 bits; no other engine allows more. +// typedMaxArguments bounds the values one statement may bind, counted over the +// whole statement. PostgreSQL's wire protocol counts parameters in 16 bits; no +// other engine allows more. const typedMaxArguments = 65535 // typedUnsupported builds an error matching dal.ErrNotSupported. Callers use it @@ -29,10 +33,31 @@ func typedUnsupported(format string, args ...any) error { // reads no database, and facts carries everything it knows about the catalog. // // Every value is a bound argument and every identifier goes through -// dialect.quoteIdent, so the returned text depends on the shape of the query -// and never on the values in it. Anything the engine cannot run faithfully -// returns an error matching dal.ErrNotSupported rather than an approximation; -// DALgo's generic engine remains the place for those queries. +// dialect.quoteIdent, so no byte of a value reaches the text. The text still +// depends on the shape of the query, and on exactly these facts about its +// values, each of which picks between fixed fragments and writes no content: +// +// - a nil constant on the right of == renders IS NULL instead of a marker; +// - the length of an IN or NOT IN list sets how many markers it writes, and an +// empty list renders the dialect's emptyIn constant; +// - a zero limit or a zero offset leaves its clause out; +// - whether a GROUP BY or ORDER BY expression that carries a constant is the +// same expression as a selected one (same shape, and constants of the same +// kind and content, see typedSameConstant) picks the select-list position or +// the inline bound expression, so two constants that are equal or not choose +// between two fixed fragments; +// - the Go type of a constant picks the cast the dialect writes beside its +// marker (dialect.bind), never its content. +// +// Catalog facts, not values, decide wildcard expansion and the NULLS clause. +// assertTypedTextIgnoresValues and assertTypedTextHasTwoShapesForTwoConstants +// (typed_sql_property_test.go) check this for any dialect. +// +// Anything the engine cannot run faithfully returns an error matching +// dal.ErrNotSupported rather than an approximation: an unrecognised node, a +// source option the statement cannot honour (see validateTypedJoinSources), a +// reference the server could not match. DALgo's generic engine remains the place +// for those queries. func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts) (string, []any, error) { if dialect == nil { return "", nil, errors.New("typed SQL compiler requires a dialect") @@ -56,12 +81,20 @@ func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCat if err := validateTypedJoins(q.From()); err != nil { return "", nil, err } + if err := validateTypedJoinSources(q.From(), "from"); err != nil { + return "", nil, err + } } c := &typedCompiler{dialect: dialect, facts: facts} text, args, err := c.query(q) if err != nil { return "", nil, err } + // The bound is on the statement, not on one IN list: lists and constants add + // up, and the driver would fail the whole statement past the limit. + if len(args) > typedMaxArguments { + return "", nil, typedUnsupported("the statement binds %d values, over the %d one statement may bind", len(args), typedMaxArguments) + } text, err = numberTypedPlaceholders(text, dialect.placeholderStyle(), len(args)) if err != nil { return "", nil, err @@ -97,6 +130,44 @@ func validateTypedJoins(from dal.FromSource) error { return err } +// validateTypedJoinSources refuses, in a statement with joins, the source +// options a single SQL join cannot honour. DALgo's generic join applies a +// source's scan (CollectionRef.WithScan: an order and a row limit taken before +// the relation joins) and reads a source in a named database from that database; +// rendering either as the whole local table would join rows the generic join +// never sees. Returning ErrNotSupported sends the query to the generic join. +// +// The same options on a lone source are accepted, because they change nothing +// there: the generic join sends each bounded relation to its adapter as a +// single-source query that restates the scan as ORDER BY and LIMIT while the +// source still carries it, so rendering the table with the statement's own +// ORDER BY and LIMIT is exactly the bounded read. Refusing that query would +// leave a dialect with no other emitter unable to run the generic join at all. +// +// Join algorithm hints (JoinedSource.Algorithms) are not read: they are +// preferences, DALgo's native join contract lets the engine choose its own plan +// (dal.NativeJoinProvider), and the server picks the algorithm. +func validateTypedJoinSources(from dal.FromSource, path string) error { + if collection, err := typedCollection(from.Base()); err == nil { + if collection.ScanLimit() > 0 || len(collection.ScanOrders()) != 0 { + return typedUnsupported("%s: join_plan: a scan-bounded source needs the generic engine", path) + } + if collection.Database() != "" { + return typedUnsupported("%s: join_plan: a source in a named database needs the generic engine", path) + } + } + for i, join := range from.Joins() { + child := join.From() + if child == nil { + child = dal.From(join.RecordsetSource) + } + if err := validateTypedJoinSources(child, fmt.Sprintf("%s.joins[%d].from", path, i)); err != nil { + return err + } + } + return nil +} + // typedCompiler carries the state of one compileTypedSQL call. type typedCompiler struct { dialect typedDialect @@ -175,9 +246,12 @@ func (c *typedCompiler) query(q dal.StructuredQuery) (string, []any, error) { func() (string, []any, error) { return c.where(q.Where()) }, func() (string, []any, error) { return c.groupBy(q.GroupBy(), items) }, func() (string, []any, error) { return c.having(q.Having(), aliases) }, - func() (string, []any, error) { return c.orderBy(q.OrderBy(), items, aliases) }, + func() (string, []any, error) { return c.orderBy(q.OrderBy(), items, aliases, len(q.GroupBy()) != 0) }, func() (string, []any, error) { clause, limitArgs := c.dialect.limitOffset(q.Limit(), q.Offset()) + if err := c.checkFragment(clause, len(limitArgs)); err != nil { + return "", nil, err + } if clause == "" { return "", nil, nil } @@ -325,20 +399,37 @@ func (c *typedCompiler) having(condition dal.Condition, aliases map[string]dal.E return " HAVING " + sql, args, nil } -func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelectItem, aliases map[string]dal.Expression) (string, []any, error) { +// orderBy renders ORDER BY, rewriting select aliases to their expressions as +// having does. grouped says the statement has a GROUP BY. +// +// An item that is a whole alias of a grouped expression carrying a constant is +// referenced by position (see byExpression). The same alias nested in a larger +// expression has no position to refer to, and bound inline it would be a fresh +// parameter that the server cannot match to GROUP BY, so it is refused as HAVING +// refuses it. Without a GROUP BY the inline form is valid and stays. +func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelectItem, aliases map[string]dal.Expression, grouped bool) (string, []any, error) { if len(orders) == 0 { return "", nil, nil } - rewriter := typedAliasRewriter{aliases: aliases} parts := make([]string, len(orders)) var args []any for i, order := range orders { + // One rewriter per item: what an earlier item resolved must not make a + // later, unrelated item look like it refers to a grouped constant. + rewriter := typedAliasRewriter{aliases: aliases} expression := rewriter.expression(order.Expression()) sql, values, err := c.byExpression(expression, items) if err != nil { return "", nil, fmt.Errorf("orderBy %d: %w", i, err) } - parts[i] = c.dialect.orderItem(sql, order.Descending(), c.notNull(expression)) + if grouped && rewriter.groupedConstant != "" && len(values) != 0 { + return "", nil, fmt.Errorf("orderBy %d: %w", i, typedUnsupported("ORDER BY refers to %q, a grouped expression carrying a constant that the server cannot match to GROUP BY", rewriter.groupedConstant)) + } + item := c.dialect.orderItem(sql, order.Descending(), c.notNull(expression)) + if err := c.checkFragment(item, 0, sql); err != nil { + return "", nil, fmt.Errorf("orderBy %d: %w", i, err) + } + parts[i] = item args = append(args, values...) } return " ORDER BY " + strings.Join(parts, ", "), args, nil @@ -368,18 +459,94 @@ func (c *typedCompiler) byExpression(expression dal.Expression, items []typedSel return sql, args, nil } -// typedOrdinal returns the 1-based position of the selected expression whose -// text equals target's, the same equality DALgo's aggregation validation uses. +// typedOrdinal returns the 1-based position of the selected expression that is +// target. Sameness is structural (typedSameExpression), not by text: DALgo's +// aggregation validation compares expressions by String(), which is empty for +// every infinity and NaN and identical for every driver.Valuer without exported +// fields, so two different constants can share a text, and a match by text +// would order or group by the selected expression instead of the written one. func typedOrdinal(items []typedSelectItem, target dal.Expression) (int, bool) { - key := target.String() for i, item := range items { - if item.expression != nil && item.expression.String() == key { + if item.expression != nil && typedSameExpression(item.expression, target) { return i + 1, true } } return 0, false } +// typedSameExpression walks two expressions in parallel and reports whether they +// are the same: the same fields, operators and aggregate calls, and constants +// that are the same by typedSameConstant. A node the compiler cannot render is +// never the same as anything. +func typedSameExpression(a, b dal.Expression) bool { + switch x := a.(type) { + case dal.FieldRef: + y, ok := b.(dal.FieldRef) + return ok && x.Equal(y) + case dal.Constant: + y, ok := b.(dal.Constant) + return ok && typedSameConstant(x.Value, y.Value) + case dal.BinaryExpression: + y, ok := b.(dal.BinaryExpression) + return ok && x.Operator == y.Operator && typedSameExpression(x.Left, y.Left) && typedSameExpression(x.Right, y.Right) + case dal.AggregateFunc: + y, ok := b.(dal.AggregateFunc) + if !ok || !strings.EqualFold(x.FuncName(), y.FuncName()) || typedIsDistinct(x) != typedIsDistinct(y) || len(x.FuncArgs()) != len(y.FuncArgs()) { + return false + } + for i, argument := range x.FuncArgs() { + if !typedSameExpression(argument, y.FuncArgs()[i]) { + return false + } + } + return true + case dal.StarExpression: + y, ok := b.(dal.StarExpression) + return ok && x.IsStar() == y.IsStar() + } + return false +} + +func typedIsDistinct(function dal.AggregateFunc) bool { + d, ok := function.(dal.DistinctAggregateFunc) + return ok && d.IsDistinct() +} + +// typedSameConstant reports whether two constants bind the same value: the same +// kind (typedKindOf) and the same content. The Go type inside a kind does not +// count, because a dialect types a placeholder by kind only, so int8(5) and +// int64(5) are one constant; NaN equals NaN, because both bind NaN; and two +// instants are the same whatever their zone. A value the compiler refuses is +// never the same as anything. +func typedSameConstant(a, b any) bool { + kindA, errA := typedKindOf(a) + kindB, errB := typedKindOf(b) + if errA != nil || errB != nil || kindA != kindB { + return false + } + valueA, valueB := reflect.ValueOf(a), reflect.ValueOf(b) + switch kindA { + case typedValueNull: + return true + case typedValueInteger: + return valueA.Int() == valueB.Int() + case typedValueUnsigned: + return valueA.Uint() == valueB.Uint() + case typedValueFloat: + floatA, floatB := valueA.Float(), valueB.Float() + return floatA == floatB || (math.IsNaN(floatA) && math.IsNaN(floatB)) + case typedValueText: + return valueA.String() == valueB.String() + case typedValueBool: + return valueA.Bool() == valueB.Bool() + case typedValueBytes: + return bytes.Equal(valueA.Bytes(), valueB.Bytes()) + case typedValueTime: + return a.(time.Time).Equal(b.(time.Time)) + } + return reflect.DeepEqual(a, b) // a driver.Valuer: its fields are all there is to compare +} + // notNull reports whether expression is a column the catalog says is NOT NULL // and that no outer join can null out. func (c *typedCompiler) notNull(expression dal.Expression) bool { @@ -415,6 +582,7 @@ type typedRelation struct { // compiler: a subtree starts with an empty visible scope, and a nested ON that // names an alias outside its subtree is refused because a parenthesised JOIN // cannot represent it. outer lists the identities visible around this subtree. +// Join algorithm hints are not read (see validateTypedJoinSources). func (c *typedCompiler) relation(from dal.FromSource, path string, outer map[string]struct{}) (typedRelation, error) { text, source, err := c.table(from.Base()) if err != nil { @@ -608,6 +776,13 @@ func typedQuerySources(from dal.FromSource) []typedSourceName { // field renders a field reference. Inside a JOIN every field must name its // source; elsewhere an unqualified field belongs to the single FROM source. func (c *typedCompiler) field(field dal.FieldRef, sources map[string]typedSource, requireQualified bool) (string, error) { + // dal.DocumentID() names the record's identity, which adapters map to their + // own identity field. The compiler has no mapping yet, and rendering it as a + // column called __name__ would read a column that does not exist. dal.ID(name, + // value) is a different field: it names a real column and is rendered as one. + if field.IsID() && field.Name() == dal.DocumentID().Name() { + return "", typedUnsupported("dal.DocumentID() has no column; mapping it to the primary key is not implemented") + } name, err := c.quote(field.Name()) if err != nil { return "", err @@ -648,9 +823,18 @@ func (c *typedCompiler) bind(value any) (string, []any, error) { if err != nil { return "", nil, err } + if err := c.checkFragment(marker, 1); err != nil { + return "", nil, err + } return marker, []any{arg}, nil } +// checkFragment applies operand rule 1 of the typedDialect contract (see +// checkTypedFragment) to one fragment the dialect wrote. +func (c *typedCompiler) checkFragment(fragment string, own int, operands ...string) error { + return checkTypedFragment(fragment, c.dialect.placeholderStyle().IdentQuote, own, operands...) +} + func (c *typedCompiler) expr(expression dal.Expression) (string, []any, error) { switch e := expression.(type) { case dal.FieldRef: @@ -696,7 +880,12 @@ func (c *typedCompiler) aggregate(function dal.AggregateFunc) (string, []any, er if d, ok := function.(dal.DistinctAggregateFunc); ok && d.IsDistinct() { distinct = "DISTINCT " } - return c.dialect.aggregateResult(name, name+"("+distinct+argument+")"), args, nil + call := name + "(" + distinct + argument + ")" + result := c.dialect.aggregateResult(name, call) + if err := c.checkFragment(result, 0, call); err != nil { + return "", nil, err + } + return result, args, nil } func (c *typedCompiler) binary(expression dal.BinaryExpression) (string, []any, error) { @@ -715,7 +904,11 @@ func (c *typedCompiler) binary(expression dal.BinaryExpression) (string, []any, } args := slices.Concat(leftArgs, rightArgs) if expression.Operator == dal.Divide { - return c.dialect.divide(left, right), args, nil + quotient := c.dialect.divide(left, right) + if err := c.checkFragment(quotient, 0, left, right); err != nil { + return "", nil, err + } + return quotient, args, nil } return "(" + left + " " + string(expression.Operator) + " " + right + ")", args, nil } @@ -784,7 +977,11 @@ func (c *typedCompiler) membership(left string, leftArgs []any, comparison dal.C } negated := comparison.Operator == dal.NotIn if len(values) == 0 { - return c.dialect.emptyIn(negated), nil, nil + constant := c.dialect.emptyIn(negated) + if err := c.checkFragment(constant, 0); err != nil { + return "", nil, err + } + return constant, nil, nil } if len(values) > typedMaxArguments { return "", nil, typedUnsupported("an IN list of %d values is too many to bind", len(values)) diff --git a/typed_sql_equality_test.go b/typed_sql_equality_test.go new file mode 100644 index 0000000..442044a --- /dev/null +++ b/typed_sql_equality_test.go @@ -0,0 +1,171 @@ +package dalgo2sql + +import ( + "database/sql/driver" + "math" + "testing" + "time" + + "github.com/dal-go/dalgo/dal" +) + +// fakeTypedOpaqueValuer is a driver.Valuer with no exported field: every +// instance has the same text under dal.Constant.String(), whatever it holds. +type fakeTypedOpaqueValuer struct{ n int } + +func (fakeTypedOpaqueValuer) Value() (driver.Value, error) { return nil, nil } + +type fakeTypedNamedText string + +type fakeTypedNamedBytes []byte + +func TestCompileTypedSQLOrdinalMatchesOnlyTheSameConstants(t *testing.T) { + // ORDER BY (AlbumId + ) against a selected (AlbumId + ). + // dal.Constant.String() is empty for every infinity and NaN and identical for + // every opaque Valuer, so a match by text would sort by the selected + // expression while the caller wrote another one. + orderedBy := func(selected, ordered any) dal.StructuredQuery { + return typedTestFrom("Album", "").NewQuery(). + OrderBy(dal.Ascending(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(ordered)))). + SelectColumns(typedTestColumn(typedTestField("Title"), ""), typedTestColumn(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(selected)), "shifted")) + } + anyMarker := newFakeTypedDialect() + anyMarker.bindMarkers = "?" + inf, negInf := math.Inf(1), math.Inf(-1) + runTypedGoldens(t, []typedGolden{ + { + name: "the same infinity is matched", + query: orderedBy(inf, inf), + wantSQL: `SELECT "Title", ("AlbumId" + $1::numeric) AS "shifted" FROM "Album" ORDER BY 2 ASC NULLS FIRST`, + wantArgs: []any{inf}, + }, + { + name: "two infinities with the same text but opposite signs are not", + query: orderedBy(inf, negInf), + wantSQL: `SELECT "Title", ("AlbumId" + $1::numeric) AS "shifted" FROM "Album" ORDER BY ("AlbumId" + $2::numeric) ASC NULLS FIRST`, + wantArgs: []any{inf, negInf}, + }, + { + name: "an integer of another width but the same value is matched", + query: orderedBy(1, int64(1)), + wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY 2 ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "a signed and an unsigned integer are different kinds and are not matched", + query: orderedBy(1, uint(1)), + wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY ("AlbumId" + $2::bigint) ASC NULLS FIRST`, + wantArgs: []any{1, uint(1)}, + }, + { + name: "opaque valuers holding the same value are matched", + dialect: anyMarker, + query: orderedBy(fakeTypedOpaqueValuer{1}, fakeTypedOpaqueValuer{1}), + wantSQL: `SELECT "Title", ("AlbumId" + $1) AS "shifted" FROM "Album" ORDER BY 2 ASC NULLS FIRST`, + wantArgs: []any{fakeTypedOpaqueValuer{1}}, + }, + { + name: "opaque valuers with the same text and different contents are not", + dialect: anyMarker, + query: orderedBy(fakeTypedOpaqueValuer{1}, fakeTypedOpaqueValuer{2}), + wantSQL: `SELECT "Title", ("AlbumId" + $1) AS "shifted" FROM "Album" ORDER BY ("AlbumId" + $2) ASC NULLS FIRST`, + wantArgs: []any{fakeTypedOpaqueValuer{1}, fakeTypedOpaqueValuer{2}}, + }, + }) +} + +func TestTypedSameConstant(t *testing.T) { + stamp := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + elsewhere := stamp.In(time.FixedZone("UTC+3", 3*3600)) + type shape struct{ n int } + cases := []struct { + name string + a, b any + want bool + }{ + {"nil and nil", nil, nil, true}, + {"nil and a value", nil, 0, false}, + {"a value and nil", "", nil, false}, + {"equal integers", 5, 5, true}, + {"integers of different width", int8(5), int64(5), true}, + {"different integers", 5, 6, false}, + {"equal unsigned integers", uint8(5), uint64(5), true}, + {"different unsigned integers", uint(5), uint(6), false}, + {"signed against unsigned", 5, uint(5), false}, + {"equal floats", 1.5, 1.5, true}, + {"different floats", 1.5, 2.5, false}, + {"NaN against NaN", math.NaN(), math.NaN(), true}, + {"NaN against a number", math.NaN(), 1.0, false}, + {"a number against NaN", 1.0, math.NaN(), false}, + {"the same infinity", math.Inf(1), math.Inf(1), true}, + {"opposite infinities", math.Inf(1), math.Inf(-1), false}, + {"integer against float", 1, 1.0, false}, + {"equal strings", "a", "a", true}, + {"different strings", "a", "b", false}, + {"a named string type against string", fakeTypedNamedText("a"), "a", true}, + {"string against bytes", "a", []byte("a"), false}, + {"equal booleans", true, true, true}, + {"different booleans", true, false, false}, + {"equal bytes", []byte{1, 2}, []byte{1, 2}, true}, + {"a named byte slice against a byte slice", fakeTypedNamedBytes{1}, []byte{1}, true}, + {"different bytes", []byte{1, 2}, []byte{1, 3}, false}, + {"the same instant in two zones", stamp, elsewhere, true}, + {"different instants", stamp, stamp.Add(time.Nanosecond), false}, + {"equal valuers", fakeTypedOpaqueValuer{1}, fakeTypedOpaqueValuer{1}, true}, + {"different valuers", fakeTypedOpaqueValuer{1}, fakeTypedOpaqueValuer{2}, false}, + {"a value the compiler refuses on the left", shape{1}, 1, false}, + {"a value the compiler refuses on the right", 1, shape{1}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := typedSameConstant(tc.a, tc.b); got != tc.want { + t.Fatalf("typedSameConstant(%#v, %#v) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestTypedSameExpression(t *testing.T) { + field := func(source, name string) dal.Expression { return dal.NewFieldRef(source, name) } + sum := func(distinct bool, args ...dal.Expression) dal.Expression { + return dal.NewAggregate(dal.SUM, distinct, args...) + } + plus := func(l, r dal.Expression) dal.Expression { return dal.Binary(l, dal.Add, r) } + cases := []struct { + name string + a, b dal.Expression + want bool + }{ + {"the same field", field("", "a"), field("", "a"), true}, + {"fields with different sources", field("x", "a"), field("", "a"), false}, + {"fields with different names", field("", "a"), field("", "b"), false}, + {"the document identity against a field of that name", dal.DocumentID(), field("", "__name__"), false}, + {"a field against a constant", field("", "a"), typedTestConst(1), false}, + {"equal constants", typedTestConst(1), typedTestConst(1), true}, + {"different constants", typedTestConst(1), typedTestConst(2), false}, + {"a constant against a field", typedTestConst(1), field("", "a"), false}, + {"equal arithmetic", plus(field("", "a"), typedTestConst(1)), plus(field("", "a"), typedTestConst(1)), true}, + {"arithmetic with another operator", plus(field("", "a"), typedTestConst(1)), dal.Binary(field("", "a"), dal.Subtract, typedTestConst(1)), false}, + {"arithmetic with another left", plus(field("", "a"), typedTestConst(1)), plus(field("", "b"), typedTestConst(1)), false}, + {"arithmetic with another right constant", plus(field("", "a"), typedTestConst(1)), plus(field("", "a"), typedTestConst(2)), false}, + {"arithmetic against a field", plus(field("", "a"), typedTestConst(1)), field("", "a"), false}, + {"equal aggregates", sum(false, field("", "a")), sum(false, field("", "a")), true}, + {"an aggregate name in another case", dal.NewAggregate("sum", false, field("", "a")), sum(false, field("", "a")), true}, + {"aggregates of different functions", sum(false, field("", "a")), dal.NewAggregate(dal.MAX, false, field("", "a")), false}, + {"aggregates differing in DISTINCT", sum(true, field("", "a")), sum(false, field("", "a")), false}, + {"aggregates with different arguments", sum(false, plus(field("", "a"), typedTestConst(1))), sum(false, plus(field("", "a"), typedTestConst(2))), false}, + {"aggregates of different arity", sum(false, field("", "a")), sum(false, field("", "a"), field("", "b")), false}, + {"an aggregate against a field", sum(false, field("", "a")), field("", "a"), false}, + {"COUNT(*) against COUNT(*)", dal.NewAggregate(dal.COUNT, false, dal.Star()), dal.NewAggregate(dal.COUNT, false, dal.Star()), true}, + {"COUNT(*) against COUNT(a)", dal.NewAggregate(dal.COUNT, false, dal.Star()), dal.NewAggregate(dal.COUNT, false, field("", "a")), false}, + {"COUNT(a) against COUNT(*)", dal.NewAggregate(dal.COUNT, false, field("", "a")), dal.NewAggregate(dal.COUNT, false, dal.Star()), false}, + {"a node the compiler cannot render", dal.NewParam("p"), dal.NewParam("p"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := typedSameExpression(tc.a, tc.b); got != tc.want { + t.Fatalf("typedSameExpression(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go index d48c127..22a5b2d 100644 --- a/typed_sql_errors_test.go +++ b/typed_sql_errors_test.go @@ -28,6 +28,16 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) noGroupBy := newFakeTypedDialect() noGroupBy.caps.GroupBy = false parent := record.NewKeyWithID("Parent", "p1") + bucket := dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1)) + bucketColumns := []dal.Column{typedTestColumn(bucket, "bucket"), typedTestColumn(dal.NewAggregate(dal.COUNT, false, dal.Star()), "n")} + scanned := dal.NewRootCollectionRef("Invoice", "i").WithScan(10, dal.Descending(typedTestField("Total"))) + scanLimitOnly := dal.NewRootCollectionRef("Invoice", "i").WithScan(10) + scanOrderOnly := dal.NewRootCollectionRef("Customer", "c").WithScan(0, dal.Ascending(typedTestField("Name"))) + plainCustomer := dal.NewRootCollectionRef("Customer", "c") + customerJoin := typedTestJoinOn("i", "CustomerId", "c", "CustomerId") + joinedTo := func(base dal.RecordsetSource, joined dal.RecordsetSource) dal.StructuredQuery { + return dal.From(base).Join(dal.NewJoinedSource(joined, dal.JoinInner, customerJoin)).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("c", "Name"), "")) + } cases := []struct { name string @@ -79,6 +89,61 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), "bucket"), dal.CountAs(typedTestField("a"), "n")), nil, "grouped expression", }, + { + "ORDER BY over a grouped alias nested in a larger expression", + album().GroupBy(bucket). + OrderBy(dal.Ascending(dal.Binary(typedTestField("bucket"), dal.Multiply, typedTestConst(2)))). + SelectColumns(bucketColumns...), + nil, "ORDER BY refers to \"bucket\"", + }, + { + "ORDER BY over a grouped alias added to an aggregate", + album().GroupBy(bucket). + OrderBy(dal.Ascending(dal.Binary(typedTestField("bucket"), dal.Add, typedTestField("n")))). + SelectColumns(bucketColumns...), + nil, "ORDER BY refers to \"bucket\"", + }, + { + "ORDER BY nesting a grouped alias after an item that is fine", + album().GroupBy(bucket). + OrderBy(dal.Ascending(typedTestField("bucket")), dal.Ascending(dal.Binary(typedTestField("bucket"), dal.Multiply, typedTestConst(2)))). + SelectColumns(bucketColumns...), + nil, "orderBy 1", + }, + {"scan-bounded base source in a join", joinedTo(scanned, plainCustomer), nil, "from: join_plan: a scan-bounded source"}, + {"scan limit alone on the base source of a join", joinedTo(scanLimitOnly, plainCustomer), nil, "scan-bounded"}, + {"scan order alone on a joined source", joinedTo(dal.NewRootCollectionRef("Invoice", "i"), scanOrderOnly), nil, "from.joins[0].from: join_plan: a scan-bounded source"}, + {"scan-bounded pointer source in a join", joinedTo(typedTestPtr(scanned), plainCustomer), nil, "scan-bounded"}, + { + "scan-bounded source inside a nested join subtree", + dal.From(dal.NewRootCollectionRef("Invoice", "i")).Join( + dal.NewJoinedFrom(dal.From(dal.NewRootCollectionRef("Customer", "c")).Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Employee", "e").WithScan(5), dal.JoinLeft, typedTestJoinOn("c", "SupportRepId", "e", "EmployeeId")), + ), dal.JoinInner, customerJoin), + ).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("c", "Name"), "")), + nil, "from.joins[0].from.joins[0].from: join_plan: a scan-bounded source", + }, + {"named-database base source in a join", joinedTo(dal.NewDatabaseCollectionRef("db1", "", "Invoice", "i"), plainCustomer), nil, "from: join_plan: a source in a named database"}, + {"named-database joined source", joinedTo(dal.NewRootCollectionRef("Invoice", "i"), dal.NewDatabaseCollectionRef("db2", "", "Customer", "c")), nil, "from.joins[0].from: join_plan: a source in a named database"}, + {"the document identity has no column to read", album().SelectColumns(typedTestColumn(dal.DocumentID(), "")), nil, "dal.DocumentID()"}, + {"the document identity in WHERE", album().Where(dal.NewComparison(dal.DocumentID(), dal.Equal, typedTestConst("x"))).SelectColumns(), nil, "dal.DocumentID()"}, + {"the document identity in ORDER BY", album().OrderBy(dal.Ascending(dal.DocumentID())).SelectColumns(), nil, "dal.DocumentID()"}, + { + "two IN lists that together bind too many values", + album().Where(dal.NewGroupCondition(dal.And, + dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray(make([]int, 40000))), + dal.NewComparison(typedTestField("b"), dal.In, dal.NewArray(make([]int, 40000))), + )).SelectColumns(), + nil, "binds 80000 values", + }, + { + "an IN list at the limit plus one more constant", + album().Where(dal.NewGroupCondition(dal.And, + dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray(make([]int, typedMaxArguments))), + typedTestEq(typedTestField("b"), 1), + )).SelectColumns(), + nil, "binds 65536 values", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -191,6 +256,207 @@ func TestCompileTypedSQLCatchesADialectThatBreaksTheMarkerContract(t *testing.T) }) } +// TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules proves the two +// rules numbered in the typedDialect contract. Both leave the marker count right, +// so the final count check alone passes them and binds values to wrong places. +func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { + album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } + // (a + 1) / (b - 2): both operands carry a value. + dividedValues := album().SelectColumns(typedTestColumn(dal.Binary( + dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, + dal.Binary(typedTestField("b"), dal.Subtract, typedTestConst(2))), "r")) + // a / b: neither operand carries a value. + dividedFields := album().SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Divide, typedTestField("b")), "r")) + sumOfProduct := album().SelectColumns(dal.SumAs(dal.Binary(typedTestField("a"), dal.Multiply, typedTestConst(2)), "s")) + orderedByValue := album().OrderBy(dal.Ascending(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)))).SelectColumns() + inEmpty := album().Where(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray([]int{}))).SelectColumns() + limited := album().Limit(5).SelectColumns() + + cases := []struct { + name string + break_ func(*fakeTypedDialect) + query dal.StructuredQuery + fragment string + }{ + { + "divide renders the right operand before the left", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + right + " / " + left + ")" } + }, + dividedValues, "verbatim and in argument order", + }, + { + "divide renders the left operand twice", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + " / " + right + " + " + left + ")" } + }, + dividedValues, "the dialect wrote", + }, + { + "divide drops the right operand", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + ")" } + }, + dividedValues, "the dialect wrote", + }, + { + "divide rewrites an operand that carries a value", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { + return "(" + strings.Replace(left, "?::bigint", "?", 1) + " / " + right + ")" + } + }, + dividedValues, "verbatim and in argument order", + }, + { + "aggregateResult renders the aggregate twice", + func(d *fakeTypedDialect) { + d.aggregateOverride = func(function, aggregate string) string { return "(" + aggregate + " + " + aggregate + ")" } + }, + sumOfProduct, "the dialect wrote", + }, + { + "aggregateResult rewrites the aggregate", + func(d *fakeTypedDialect) { + d.aggregateOverride = func(function, aggregate string) string { return strings.ToLower(aggregate) } + }, + sumOfProduct, "verbatim and in argument order", + }, + { + "orderItem renders the expression twice", + func(d *fakeTypedDialect) { + d.orderItemOverride = func(expression string, descending, notNull bool) string { return expression + ", " + expression } + }, + orderedByValue, "the dialect wrote", + }, + { + "emptyIn writes a marker", + func(d *fakeTypedDialect) { d.emptyInOverride = func(bool) string { return "(? IS NULL)" } }, + inEmpty, "the dialect wrote", + }, + { + "limitOffset writes fewer markers than arguments", + func(d *fakeTypedDialect) { + d.limitOffsetOverride = func(limit, offset int) (string, []any) { return "LIMIT ?", []any{limit, offset} } + }, + limited, "the dialect wrote", + }, + { + "orderItem rewrites the expression it was given", + func(d *fakeTypedDialect) { + d.orderItemOverride = func(expression string, descending, notNull bool) string { + return strings.Replace(expression, "?::bigint", "?", 1) + " ASC" + } + }, + orderedByValue, "verbatim and in argument order", + }, + { + "bind writes two markers for one value", + func(d *fakeTypedDialect) { d.bindMarkers = "? + ?" }, + orderedByValue, "the dialect wrote 2 placeholders where 1 belong", + }, + { + "divide adds a string literal that holds a marker", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + " / " + right + " + '?')" } + }, + dividedFields, "the dialect wrote", + }, + { + "divide adds a string literal", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + " / " + right + " + 'x')" } + }, + dividedFields, "string literal", + }, + { + "divide adds a line comment", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + " / " + right + ") -- x" } + }, + dividedFields, "comment", + }, + { + "divide adds a block comment", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + left + " / " + right + ") /* x */" } + }, + dividedFields, "comment", + }, + { + "orderItem adds a string literal", + func(d *fakeTypedDialect) { + d.orderItemOverride = func(expression string, descending, notNull bool) string { return expression + " ASC, 'x'" } + }, + orderedByValue, "string literal", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dialect := newFakeTypedDialect() + tc.break_(dialect) + text, args, err := compileTypedSQL(tc.query, dialect, typedCatalogFacts{}) + if err == nil { + t.Fatalf("compileTypedSQL() = %q, %v; want the dialect refused", text, args) + } + if errors.Is(err, dal.ErrNotSupported) { + t.Fatalf("error %q is a dialect fault, not a query the engine declines", err) + } + if text != "" || args != nil { + t.Fatalf("a refused query must return no SQL, got %q %v", text, args) + } + if !strings.Contains(err.Error(), tc.fragment) { + t.Fatalf("error %q does not mention %q", err, tc.fragment) + } + }) + } + + // Rule 1 binds only operands that carry a value; one that does not may be + // repeated, which a CASE form of divide does. + t.Run("a dialect may repeat or move an operand that carries no value", func(t *testing.T) { + dialect := newFakeTypedDialect() + dialect.divideOverride = func(left, right string) string { + return "(CASE WHEN " + right + " = 0 THEN NULL ELSE " + left + " / " + right + " END)" + } + typedGolden{ + dialect: dialect, + query: dividedFields, + wantSQL: `SELECT (CASE WHEN "b" = 0 THEN NULL ELSE "a" / "b" END) AS "r" FROM "Album"`, + }.run(t) + }) + t.Run("a dialect that wraps operands without changing them is accepted", func(t *testing.T) { + typedGolden{ + query: dividedValues, + wantSQL: `SELECT (CAST(("a" + $1::bigint) AS double precision) / NULLIF(CAST(("b" - $2::bigint) AS double precision), 0)) AS "r" FROM "Album"`, + wantArgs: []any{1, 2}, + }.run(t) + }) +} + +// TestCompileTypedSQLCatchesFaultsWhoseMarkerCountsCancel shows why each fragment +// is checked where it is written and not only by the final count: emptyIn gains a +// marker and limitOffset loses one, so the totals match the arguments and the +// numbering pass alone would accept a statement that binds the LIMIT value to +// the marker emptyIn invented. +func TestCompileTypedSQLCatchesFaultsWhoseMarkerCountsCancel(t *testing.T) { + q := typedTestFrom("Album", "").NewQuery(). + Where(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray([]int{}))). + Limit(5).SelectColumns() + dialect := newFakeTypedDialect() + dialect.emptyInOverride = func(bool) string { return "(? IS NULL)" } + dialect.limitOffsetOverride = func(limit, offset int) (string, []any) { return "LIMIT 5", []any{limit} } + + // Without the per-fragment checks this text passes the final count. + text := `SELECT * FROM "Album" WHERE (? IS NULL) LIMIT 5` + if _, err := numberTypedPlaceholders(text, dialect.placeholderStyle(), 1); err != nil { + t.Fatalf("the cancelling faults were meant to pass the final count, got %v", err) + } + gotText, gotArgs, err := compileTypedSQL(q, dialect, typedCatalogFacts{}) + if err == nil || !strings.Contains(err.Error(), "the dialect wrote") || gotText != "" || gotArgs != nil { + t.Fatalf("compileTypedSQL() = %q, %v, %v; want a refusal naming the faulty fragment", gotText, gotArgs, err) + } +} + func TestCompileTypedSQLCheckedJoinKeyTypes(t *testing.T) { facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ {Name: "Album"}: {Columns: []typedColumnFact{ diff --git a/typed_sql_property_test.go b/typed_sql_property_test.go index b7bf82c..87363a4 100644 --- a/typed_sql_property_test.go +++ b/typed_sql_property_test.go @@ -78,12 +78,115 @@ func typedPropertyQuery(d typedDraw) dal.StructuredQuery { SelectColumns(typedTestColumn(bucket, "bucket"), dal.SumAs(typedTestField("Total"), "total")) } -func (d typedDraw) wantArgs() []any { - return []any{ - d.i1, - d.i2, d.f1, d.s1, d.flag, d.stamp, d.blob, d.i3, d.s2, d.f2, d.s3, - d.i4, - d.limit, d.offset, +// typedBoundArgument is the argument dialect sends beside the text for value. +func typedBoundArgument(t *testing.T, dialect typedDialect, value any) any { + t.Helper() + _, arg, err := dialect.bind(value) + if err != nil { + t.Fatalf("dialect.bind(%#v) error = %v", value, err) + } + return arg +} + +// wantArgs lists the arguments typedPropertyQuery binds, in text order, as +// dialect spells them. +func (d typedDraw) wantArgs(t *testing.T, dialect typedDialect) []any { + t.Helper() + bound := func(value any) any { return typedBoundArgument(t, dialect, value) } + _, page := dialect.limitOffset(d.limit, d.offset) + return append([]any{ + bound(d.i1), + bound(d.i2), bound(d.f1), bound(d.s1), bound(d.flag), bound(d.stamp), bound(d.blob), bound(d.i3), bound(d.s2), bound(d.f2), bound(d.s3), + bound(d.i4), + }, page...) +} + +// assertTypedTextIgnoresValues is the value-independence proof every typedDialect +// must pass: 300 seeded draws of every constant, and the SQL text may not change +// by a byte. It takes the dialect, so the PostgreSQL dialect runs the same draws +// as the fake one; a dialect that typed a placeholder by the content of a value +// (as opposed to its Go type) fails here. +func assertTypedTextIgnoresValues(t *testing.T, dialect typedDialect) { + t.Helper() + style := dialect.placeholderStyle() + r := rand.New(rand.NewPCG(20261004, 7)) + var reference string + for i := range 300 { + draw := drawTypedValues(r) + text, args, err := compileTypedSQL(typedPropertyQuery(draw), dialect, typedCatalogFacts{}) + if err != nil { + t.Fatalf("draw %d: compileTypedSQL() error = %v", i, err) + } + if i == 0 { + reference = text + } else if text != reference { + t.Fatalf("draw %d changed the SQL text\n got: %s\nwant: %s", i, text, reference) + } + if want := draw.wantArgs(t, dialect); !reflect.DeepEqual(args, want) { + t.Fatalf("draw %d: args = %#v, want %#v", i, args, want) + } + for _, value := range []string{draw.s1, draw.s2, draw.s3} { + if strings.Contains(text, value) { + t.Fatalf("draw %d: value %q reached the SQL text %q", i, value, text) + } + } + if style.Prefix != "" { + for n := 1; n <= len(args); n++ { + if !strings.Contains(text, fmt.Sprintf("%s%d", style.Prefix, n)) { + t.Fatalf("draw %d: placeholder %s%d is missing from %q", i, style.Prefix, n, text) + } + } + if strings.Contains(text, fmt.Sprintf("%s%d", style.Prefix, len(args)+1)) { + t.Fatalf("draw %d: more placeholders than the %d arguments in %q", i, len(args), text) + } + } + } +} + +// assertTypedTextHasTwoShapesForTwoConstants covers the one structural choice +// that depends on two values at once: a selected expression and an ORDER BY +// expression that differ only in a constant. The text is exactly one of two +// fixed strings, "ORDER BY 2" when the constants are equal and the inline bound +// expression when they are not. Each constant is drawn independently, from a +// range small enough to meet equality often. +func assertTypedTextHasTwoShapesForTwoConstants(t *testing.T, dialect typedDialect) { + t.Helper() + compile := func(selected, ordered int) (string, []any) { + q := typedTestFrom("Album", "").NewQuery(). + OrderBy(dal.Ascending(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(ordered)))). + SelectColumns(typedTestColumn(typedTestField("Title"), ""), typedTestColumn(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(selected)), "shifted")) + text, args, err := compileTypedSQL(q, dialect, typedCatalogFacts{}) + if err != nil { + t.Fatalf("compileTypedSQL(%d, %d) error = %v", selected, ordered, err) + } + return text, args + } + sameText, _ := compile(1, 1) + differentText, _ := compile(1, 2) + if sameText == differentText { + t.Fatalf("equal and different constants compile to the same text %q: the property would prove nothing", sameText) + } + r := rand.New(rand.NewPCG(20261004, 11)) + equal, different := 0, 0 + for i := range 300 { + selected, ordered := r.IntN(4), r.IntN(4) + text, args := compile(selected, ordered) + want, wantArgs := differentText, []any{typedBoundArgument(t, dialect, selected), typedBoundArgument(t, dialect, ordered)} + if selected == ordered { + want, wantArgs = sameText, wantArgs[:1] + equal++ + } else { + different++ + } + if text != want { + t.Fatalf("draw %d (selected %d, ordered %d): text = %q, want one of the two fixed texts, here %q", i, selected, ordered, text, want) + } + if !reflect.DeepEqual(args, wantArgs) { + t.Fatalf("draw %d (selected %d, ordered %d): args = %#v, want %#v", i, selected, ordered, args, wantArgs) + } + } + if equal == 0 || different == 0 { + t.Fatalf("the draws met equal constants %d times and different ones %d times; both must occur", equal, different) } } @@ -96,38 +199,21 @@ func TestCompileTypedSQLTextDoesNotDependOnValues(t *testing.T) { t.Run(name, func(t *testing.T) { dialect := newFakeTypedDialect() dialect.style = style - r := rand.New(rand.NewPCG(20261004, 7)) - var reference string - for i := range 300 { - draw := drawTypedValues(r) - text, args, err := compileTypedSQL(typedPropertyQuery(draw), dialect, typedCatalogFacts{}) - if err != nil { - t.Fatalf("draw %d: compileTypedSQL() error = %v", i, err) - } - if i == 0 { - reference = text - } else if text != reference { - t.Fatalf("draw %d changed the SQL text\n got: %s\nwant: %s", i, text, reference) - } - if want := draw.wantArgs(); !reflect.DeepEqual(args, want) { - t.Fatalf("draw %d: args = %#v, want %#v", i, args, want) - } - for _, value := range []string{draw.s1, draw.s2, draw.s3} { - if strings.Contains(text, value) { - t.Fatalf("draw %d: value %q reached the SQL text %q", i, value, text) - } - } - if style.Prefix != "" { - for n := 1; n <= len(args); n++ { - if !strings.Contains(text, fmt.Sprintf("$%d", n)) { - t.Fatalf("draw %d: placeholder $%d is missing from %q", i, n, text) - } - } - if strings.Contains(text, fmt.Sprintf("$%d", len(args)+1)) { - t.Fatalf("draw %d: more placeholders than the %d arguments in %q", i, len(args), text) - } - } - } + assertTypedTextIgnoresValues(t, dialect) + }) + } +} + +func TestCompileTypedSQLTwoConstantsChooseBetweenTwoFixedTexts(t *testing.T) { + styles := map[string]typedPlaceholderStyle{ + "numbered": {Prefix: "$", IdentQuote: '"'}, + "question": {IdentQuote: '"'}, + } + for name, style := range styles { + t.Run(name, func(t *testing.T) { + dialect := newFakeTypedDialect() + dialect.style = style + assertTypedTextHasTwoShapesForTwoConstants(t, dialect) }) } } @@ -144,7 +230,7 @@ func TestCompileTypedSQLPropertyQueryShape(t *testing.T) { `WHERE ("a" = $2::bigint AND "b" > $3::numeric AND "c" = $4 AND "d" = $5::boolean AND "e" <= $6::timestamptz AND "f" = $7::bytea ` + `AND "g" IN ($8::bigint, $9, $10::numeric) AND "h" NOT IN ($11)) GROUP BY 1 ` + `HAVING CAST(SUM("Total") AS double precision) > $12::bigint ORDER BY 1 ASC NULLS FIRST LIMIT $13 OFFSET $14`, - wantArgs: draw.wantArgs(), + wantArgs: draw.wantArgs(t, newFakeTypedDialect()), }.run(t) } @@ -162,3 +248,36 @@ func TestCompileTypedSQLNilChangesStructureNotValues(t *testing.T) { t.Fatalf("== 1 compiled to %q, %v", valueText, err) } } + +func TestCompileTypedSQLListLengthAndZeroLimitChangeStructureNotValues(t *testing.T) { + // The other two documented structural dependences: the number of markers an + // IN list writes follows its length, and a zero limit or offset leaves its + // clause out. Neither reads a value's content. + inList := func(values ...int) string { + q := typedTestFrom("Album", "").NewQuery().Where(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray(values))).SelectColumns() + text, _, err := compileTypedSQL(q, newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil { + t.Fatal(err) + } + return text + } + if a, b := inList(1, 2, 3), inList(7, 8, 9); a != b { + t.Fatalf("two lists of three differ: %q and %q", a, b) + } + if a, b := inList(1, 2, 3), inList(1, 2); a == b { + t.Fatalf("lists of different length compiled to the same text %q", a) + } + page := func(limit, offset int) string { + text, _, err := compileTypedSQL(typedTestFrom("Album", "").NewQuery().Limit(limit).Offset(offset).SelectColumns(), newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil { + t.Fatal(err) + } + return text + } + if a, b := page(5, 9), page(77, 1); a != b { + t.Fatalf("two non-zero pages differ: %q and %q", a, b) + } + if a, b := page(5, 9), page(0, 9); a == b { + t.Fatalf("a zero limit compiled to the same text %q", a) + } +} diff --git a/typed_sql_test.go b/typed_sql_test.go index bc9d83e..4629a41 100644 --- a/typed_sql_test.go +++ b/typed_sql_test.go @@ -1,6 +1,7 @@ package dalgo2sql import ( + "strings" "testing" "time" @@ -314,6 +315,46 @@ func TestCompileTypedSQLAggregates(t *testing.T) { wantSQL: `SELECT "Country" AS "c", COUNT("Id") AS "n" FROM "Invoice" GROUP BY "Country" HAVING "Country" = $1`, wantArgs: []any{"FR"}, }, + { + name: "a constant inside an aggregate argument is bound", + query: invoice().SelectColumns( + dal.SumAs(dal.Binary(typedTestField("B"), dal.Multiply, typedTestConst(2)), "s"), + dal.CountAs(typedTestField("B"), "c"), + ), + wantSQL: `SELECT CAST(SUM(("B" * $1::bigint)) AS double precision) AS "s", COUNT("B") AS "c" FROM "Invoice"`, + wantArgs: []any{2}, + }, + { + name: "an aggregate argument carrying a constant is rewritten in HAVING and ORDER BY", + query: invoice(). + GroupBy(typedTestField("Country")). + Having(dal.NewComparison(typedTestField("s"), dal.GreaterThen, typedTestConst(5))). + OrderBy(dal.Descending(typedTestField("s"))). + SelectColumns(typedTestColumn(typedTestField("Country"), ""), dal.SumAs(dal.Binary(typedTestField("B"), dal.Multiply, typedTestConst(2)), "s")), + wantSQL: `SELECT "Country", CAST(SUM(("B" * $1::bigint)) AS double precision) AS "s" FROM "Invoice" GROUP BY "Country" ` + + `HAVING CAST(SUM(("B" * $2::bigint)) AS double precision) > $3::bigint ORDER BY 2 DESC NULLS LAST`, + wantArgs: []any{2, 2, 5}, + }, + { + name: "an aggregate alias carrying a constant, nested in ORDER BY, stays inline", + query: invoice(). + GroupBy(typedTestField("Country")). + OrderBy(dal.Ascending(dal.Binary(typedTestField("n"), dal.Multiply, typedTestConst(2)))). + SelectColumns(typedTestColumn(typedTestField("Country"), ""), typedTestColumn(dal.Binary(dal.Count().Expression, dal.Add, typedTestConst(1)), "n")), + wantSQL: `SELECT "Country", (COUNT(*) + $1::bigint) AS "n" FROM "Invoice" GROUP BY "Country" ` + + `ORDER BY ((COUNT(*) + $2::bigint) * $3::bigint) ASC NULLS FIRST`, + wantArgs: []any{1, 1, 2}, + }, + { + name: "a grouped alias by ordinal does not stop a later item carrying its own constant", + query: invoice(). + GroupBy(dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1))). + OrderBy(dal.Ascending(typedTestField("bucket")), dal.Descending(dal.Binary(typedTestField("n"), dal.Multiply, typedTestConst(2)))). + SelectColumns(typedTestColumn(dal.Binary(typedTestField("Total"), dal.Add, typedTestConst(1)), "bucket"), typedTestColumn(dal.Count().Expression, "n")), + wantSQL: `SELECT ("Total" + $1::bigint) AS "bucket", COUNT(*) AS "n" FROM "Invoice" GROUP BY 1 ` + + `ORDER BY 1 ASC NULLS FIRST, (COUNT(*) * $2::bigint) DESC NULLS LAST`, + wantArgs: []any{1, 2}, + }, { name: "having over an aggregate alias that carries a constant is fine", query: invoice(). @@ -403,6 +444,13 @@ func TestCompileTypedSQLOrderBy(t *testing.T) { wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY 2 ASC NULLS FIRST`, wantArgs: []any{1}, }, + { + name: "an alias selecting a constant-carrying expression, nested in ORDER BY of an ungrouped query, is bound inline", + query: album().OrderBy(dal.Descending(dal.Binary(typedTestField("shifted"), dal.Multiply, typedTestConst(2)))). + SelectColumns(typedTestColumn(typedTestField("Title"), ""), typedTestColumn(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)), "shifted")), + wantSQL: `SELECT "Title", ("AlbumId" + $1::bigint) AS "shifted" FROM "Album" ORDER BY (("AlbumId" + $2::bigint) * $3::bigint) DESC NULLS LAST`, + wantArgs: []any{1, 1, 2}, + }, { name: "an unselected expression carrying a constant is bound inline", query: album().OrderBy(dal.Ascending(dal.Binary(typedTestField("AlbumId"), dal.Add, typedTestConst(1)))).SelectColumns(typedTestColumn(typedTestField("Title"), "")), @@ -499,4 +547,106 @@ func TestCompileTypedSQLHostileValueNeverReachesTheText(t *testing.T) { } } +func TestCompileTypedSQLLaunchJourneyStatement(t *testing.T) { + // The launch journey in one statement: a join, WHERE, GROUP BY, HAVING and + // ORDER BY through select aliases, LIMIT and OFFSET, every field qualified, + // and values numbered across clauses in text order. + invoices := dal.NewRootCollectionRef("Invoice", "i") + customers := dal.NewRootCollectionRef("Customer", "c") + q := dal.From(invoices).Join( + dal.NewJoinedSource(customers, dal.JoinInner, typedTestJoinOn("i", "CustomerId", "c", "CustomerId")), + ).NewQuery(). + Where(dal.NewGroupCondition(dal.And, + typedTestEq(typedTestQualified("i", "BillingCountry"), "FR"), + dal.NewComparison(typedTestQualified("i", "Total"), dal.GreaterThen, typedTestConst(9.5)), + )). + GroupBy(typedTestQualified("c", "Country")). + Having(dal.NewComparison(typedTestField("revenue"), dal.GreaterThen, typedTestConst(1000))). + OrderBy(dal.Descending(typedTestField("revenue")), dal.Ascending(typedTestField("country"))). + Limit(10).Offset(5). + SelectColumns( + typedTestColumn(typedTestQualified("c", "Country"), "country"), + dal.SumAs(typedTestQualified("i", "Total"), "revenue"), + dal.CountAs(typedTestQualified("i", "InvoiceId"), "invoices"), + ) + typedGolden{ + query: q, + wantSQL: `SELECT "c"."Country" AS "country", CAST(SUM("i"."Total") AS double precision) AS "revenue", COUNT("i"."InvoiceId") AS "invoices" ` + + `FROM "Invoice" AS "i" INNER JOIN "Customer" AS "c" ON ("i"."CustomerId" = "c"."CustomerId") ` + + `WHERE ("i"."BillingCountry" = $1 AND "i"."Total" > $2::numeric) GROUP BY "c"."Country" ` + + `HAVING CAST(SUM("i"."Total") AS double precision) > $3::bigint ` + + `ORDER BY CAST(SUM("i"."Total") AS double precision) DESC NULLS LAST, "c"."Country" ASC NULLS FIRST LIMIT $4 OFFSET $5`, + wantArgs: []any{"FR", 9.5, 1000, 10, 5}, + }.run(t) +} + +func TestCompileTypedSQLQualifiersWithQuoteCharacters(t *testing.T) { + // A source alias or schema that holds the quote character or the placeholder + // character is quoted wherever it qualifies a field, and numbering skips it. + t.Run("single source", func(t *testing.T) { + typedGolden{ + query: dal.From(dal.NewQualifiedRootCollectionRef(`sch"ema`, `ta?ble`, `a"l?`)).NewQuery(). + Where(typedTestEq(typedTestQualified(`a"l?`, `x"?`), 1)). + SelectColumns(typedTestColumn(typedTestQualified(`a"l?`, `x"?`), "")), + wantSQL: `SELECT "a""l?"."x""?" FROM "sch""ema"."ta?ble" AS "a""l?" WHERE "a""l?"."x""?" = $1::bigint`, + wantArgs: []any{1}, + }.run(t) + }) + t.Run("join", func(t *testing.T) { + typedGolden{ + query: dal.From(dal.NewQualifiedRootCollectionRef(`s"1`, "Album", `a"?`)).Join( + dal.NewJoinedSource(dal.NewQualifiedRootCollectionRef(`s?2`, "Artist", `r"?"`), dal.JoinLeft, typedTestJoinOn(`a"?`, "ArtistId", `r"?"`, "ArtistId")), + ).NewQuery(). + Where(typedTestEq(typedTestQualified(`r"?"`, "Name"), "x")). + SelectColumns(typedTestColumn(typedTestQualified(`a"?`, "Title"), "")), + wantSQL: `SELECT "a""?"."Title" FROM "s""1"."Album" AS "a""?" LEFT JOIN "s?2"."Artist" AS "r""?""" ON ("a""?"."ArtistId" = "r""?"""."ArtistId") ` + + `WHERE "r""?"""."Name" = $1`, + wantArgs: []any{"x"}, + }.run(t) + }) +} + +func TestCompileTypedSQLScanBoundedSingleSource(t *testing.T) { + // A bound on a lone source is rendered as the statement's own ORDER BY and + // LIMIT, which is how DALgo's generic join asks a relation for its rows: it + // sends each bounded relation as a single-source query that restates the scan + // while the source still carries it. Refusing that leaf would leave a + // dialect without a legacy emitter unable to run DALgo's generic join at all. + scanned := dal.NewRootCollectionRef("Invoice", "i").WithScan(10, dal.Descending(typedTestField("Total"))) + t.Run("the leaf query the generic join sends", func(t *testing.T) { + typedGolden{ + query: dal.From(scanned).NewQuery().OrderBy(scanned.ScanOrders()...).Limit(scanned.ScanLimit()).SelectColumns(), + wantSQL: `SELECT * FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, + wantArgs: []any{10}, + }.run(t) + }) + t.Run("a pointer source", func(t *testing.T) { + typedGolden{ + query: dal.From(typedTestPtr(scanned)).NewQuery().SelectColumns(), + wantSQL: `SELECT * FROM "Invoice" AS "i"`, + }.run(t) + }) +} + +func TestCompileTypedSQLIdentityFields(t *testing.T) { + // dal.ID names a real column and is rendered as one; dal.DocumentID has no + // column of its own and is refused (see the unsupported-node table). + typedGolden{ + query: typedTestFrom("Album", "").NewQuery().Where(dal.ID("AlbumId", 7)).SelectColumns(), + wantSQL: `SELECT * FROM "Album" WHERE "AlbumId" = $1::bigint`, + wantArgs: []any{7}, + }.run(t) +} + +func TestCompileTypedSQLArgumentLimitIsPerStatement(t *testing.T) { + // Exactly the limit is accepted; the refusal tests cover one more. + q := typedTestFrom("Album", "").NewQuery(). + Where(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray(make([]int, typedMaxArguments)))). + SelectColumns() + text, args, err := compileTypedSQL(q, newFakeTypedDialect(), typedCatalogFacts{}) + if err != nil || len(args) != typedMaxArguments || !strings.HasSuffix(text, "$65535::bigint)") { + t.Fatalf("compileTypedSQL() with %d values: %d args, error %v", typedMaxArguments, len(args), err) + } +} + func typedTestPtr[T any](value T) *T { return &value } From 60b2b4d69e918b5031fd5f7631b6c3dd2f250f68 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 21:57:47 +0100 Subject: [PATCH 3/6] fix(typed-sql): refuse a lone scan-bounded source unless the statement restates its scan A lone source with WithScan compiled as the whole table for every statement shape. It now compiles only for the leaf query DALgo builds (ORDER BY equal to the scan orders after alias resolution, LIMIT within the scan limit, no WHERE, aggregation or OFFSET) and otherwise returns ErrNotSupported. Also: probe divide with two different marker-carrying operands so operands with identical text cannot hide a swap; drop DALgo's grouping-validation text, which quotes the query's constants, from the compile error; broaden the property draws (whole, zero and negative floats, integers beyond 32 bits, digit-only, date-looking and empty strings) and prove seven content-typing dialects fail the proof; state each limit in the dialect contract. Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 11 +- typed_dialect_fake_test.go | 4 + typed_placeholders.go | 5 + typed_sql.go | 137 +++++++++++++++++++++---- typed_sql_errors_test.go | 92 +++++++++++++++++ typed_sql_property_test.go | 199 ++++++++++++++++++++++++++++++++++--- typed_sql_test.go | 79 ++++++++++++--- 7 files changed, 483 insertions(+), 44 deletions(-) diff --git a/typed_dialect.go b/typed_dialect.go index 3243791..8d8ac9c 100644 --- a/typed_dialect.go +++ b/typed_dialect.go @@ -41,7 +41,14 @@ import ( // writes divide's right operand before its left, or repeats one, keeps the // marker count right and binds values to the wrong positions. An operand // without a marker carries no argument and may be repeated, moved or -// wrapped freely. +// wrapped freely. The check reads text, and two operands with the same text +// cannot be told apart by it, as in (a + 1) / (a + 2). So whenever both +// operands of a division carry a marker, the compiler also calls divide +// once with two different probe operands that carry one marker each and +// applies the check to that fragment. divide must therefore be a function +// of its two operand texts alone; it is called more than once per +// statement. aggregateResult and orderItem take one operand, so they have +// no order to get wrong. // - Operand rule 2, no literal and no comment (checked): a fragment is // quoted identifiers, keywords, punctuation and the marker, never a string // literal and never a comment. The numbering pass understands quoted @@ -50,6 +57,8 @@ import ( // - Fragments that stand for an expression (divide, aggregateResult) must be // self-delimiting, that is wrapped in parentheses or a function call, so // the compiler can place them next to any operator. +// - An error a dialect returns must not quote a value: the compiler passes it +// on, and a server logs it. Name the type or the rule, never the content. // // The interface stays unexported until three dialects exist. type typedDialect interface { diff --git a/typed_dialect_fake_test.go b/typed_dialect_fake_test.go index f8823cb..c1dbbf1 100644 --- a/typed_dialect_fake_test.go +++ b/typed_dialect_fake_test.go @@ -23,6 +23,7 @@ type fakeTypedDialect struct { // contract in typed_dialect.go. quoteOverride func(string) (string, error) bindMarkers string + bindOverride func(value any) (marker string, arg any, err error) divideOverride func(left, right string) string aggregateOverride func(function, aggregate string) string orderItemOverride func(expression string, descending, notNull bool) string @@ -74,6 +75,9 @@ type fakeTypedFailingValuer struct{} func (fakeTypedFailingValuer) Value() (driver.Value, error) { return nil, nil } func (d *fakeTypedDialect) bind(value any) (string, any, error) { + if d.bindOverride != nil { + return d.bindOverride(value) + } kind, err := typedKindOf(value) if err != nil { return "", nil, err diff --git a/typed_placeholders.go b/typed_placeholders.go index 3440b72..019977e 100644 --- a/typed_placeholders.go +++ b/typed_placeholders.go @@ -108,6 +108,11 @@ func countTypedMarkers(text string, quote byte) int { // two operands would keep the count right and bind values to the wrong places. // An operand without a marker carries no argument, so it may be repeated, moved // or rewritten freely. +// +// The check sees text only. Two operands with the same text (the quotient +// (a + 1) / (a + 2) renders both as ("a" + ?::bigint)) are indistinguishable to +// it, so a swap of them passes. The one fragment with two operands that can meet +// that case is divide, and checkDivideOrder probes it with operands that differ. func checkTypedFragment(fragment string, quote byte, own int, operands ...string) error { want := own for _, operand := range operands { diff --git a/typed_sql.go b/typed_sql.go index 38b19af..aca993b 100644 --- a/typed_sql.go +++ b/typed_sql.go @@ -53,11 +53,16 @@ func typedUnsupported(format string, args ...any) error { // assertTypedTextIgnoresValues and assertTypedTextHasTwoShapesForTwoConstants // (typed_sql_property_test.go) check this for any dialect. // +// Error messages follow the same rule: they quote identifiers and name the rule +// that failed, never a constant. DALgo's grouping validation words its message +// with the query's own expressions, so it is replaced (errTypedInvalidAggregation); +// an error a dialect returns is the dialect's to keep clean (see typedDialect). +// // Anything the engine cannot run faithfully returns an error matching // dal.ErrNotSupported rather than an approximation: an unrecognised node, a -// source option the statement cannot honour (see validateTypedJoinSources), a -// reference the server could not match. DALgo's generic engine remains the place -// for those queries. +// source option the statement cannot honour (see validateTypedJoinSources and +// validateTypedScanRestated), a reference the server could not match. DALgo's +// generic engine remains the place for those queries. func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts) (string, []any, error) { if dialect == nil { return "", nil, errors.New("typed SQL compiler requires a dialect") @@ -84,6 +89,8 @@ func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCat if err := validateTypedJoinSources(q.From(), "from"); err != nil { return "", nil, err } + } else if err := validateTypedScanRestated(q); err != nil { + return "", nil, err } c := &typedCompiler{dialect: dialect, facts: facts} text, args, err := c.query(q) @@ -102,9 +109,17 @@ func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCat return text, args, nil } +// errTypedInvalidAggregation is the whole of what the compiler says when DALgo's +// grouping validation rejects a query. That validation words its message with the +// query's own expressions, constants included (selected expression "(a + +// 'secret')" is neither aggregated nor present in GROUP BY), and a server logs +// compile errors, so the message is dropped and the rule that failed is named. +var errTypedInvalidAggregation = errors.New("invalid aggregation: every selected, HAVING and ORDER BY expression must be an aggregate, a GROUP BY expression or a select alias, " + + "select aliases must be unique and aggregates may not nest; the detail is left out because it would quote the query's constants") + func validateTypedAggregation(q dal.StructuredQuery, dialect typedDialect) error { - if err := dal.ValidateAggregation(q); err != nil { - return fmt.Errorf("invalid aggregation: %w", err) + if dal.ValidateAggregation(q) != nil { + return errTypedInvalidAggregation } plan, err := dal.PlanAggregation(q, dialect.capabilities()) if err != nil { @@ -137,12 +152,8 @@ func validateTypedJoins(from dal.FromSource) error { // rendering either as the whole local table would join rows the generic join // never sees. Returning ErrNotSupported sends the query to the generic join. // -// The same options on a lone source are accepted, because they change nothing -// there: the generic join sends each bounded relation to its adapter as a -// single-source query that restates the scan as ORDER BY and LIMIT while the -// source still carries it, so rendering the table with the statement's own -// ORDER BY and LIMIT is exactly the bounded read. Refusing that query would -// leave a dialect with no other emitter unable to run the generic join at all. +// A lone source is different, and validateTypedScanRestated decides what a +// scan-bounded one compiles to. // // Join algorithm hints (JoinedSource.Algorithms) are not read: they are // preferences, DALgo's native join contract lets the engine choose its own plan @@ -168,6 +179,73 @@ func validateTypedJoinSources(from dal.FromSource, path string) error { return nil } +// validateTypedScanRestated decides what a statement over one scan-bounded +// source (CollectionRef.WithScan: an order and a row limit) compiles to. A scan +// means: take the ordered, bounded rows first, then apply the rest of the +// statement to them. A SELECT over the table can say that only when the +// statement is the bounded read itself, which is the leaf query DALgo builds for +// a bounded relation (dal/join_execute.go, dal/federated_query.go, +// dal/federated_stream.go): the scan restated as ORDER BY and LIMIT, with no +// filter, grouping or offset. Anything else would be rendered over the whole +// table (a WHERE would filter before the bound, not after it), so it returns +// ErrNotSupported and DALgo's generic engine runs it. +// +// The statement restates the scan when ORDER BY equals the scan's orders item +// by item (after select aliases are resolved, as the compiled text resolves +// them), in the same direction, and, if the scan has a limit, the statement's +// LIMIT is set and no larger. The projection is free: a projection after a limit +// reads the same rows. A statement limit below the scan's is the federated +// executor's own tightening and is accepted. +// +// Database() is not checked. A lone source in a named database is right only +// because the federated executor already routed the leaf to that database's +// connection, which the compiler cannot verify: it renders the table by its own +// name. SQL-04 must give the compiler the connection's own database name so it +// can compare. +func validateTypedScanRestated(q dal.StructuredQuery) error { + collection, err := typedCollection(q.From().Base()) + if err != nil || (collection.ScanLimit() == 0 && len(collection.ScanOrders()) == 0) { + return nil // no scan; typedCompiler.table refuses a source it cannot render + } + refuse := func(why string) error { + return typedUnsupported("from: a scan-bounded source compiles only when the statement restates its scan: %s", why) + } + switch { + case q.Where() != nil: + return refuse("WHERE filters after the scan, not before it") + case dal.HasAggregation(q): + return refuse("aggregation runs over the scanned rows, not the table") + case q.Offset() != 0: + return refuse("OFFSET skips rows of the scan") + case collection.ScanLimit() > 0 && (q.Limit() == 0 || q.Limit() > collection.ScanLimit()): + return refuse("LIMIT is missing or larger than the scan limit") + } + scanOrders, orders := collection.ScanOrders(), q.OrderBy() + if len(orders) != len(scanOrders) { + return refuse("ORDER BY differs from the scan order") + } + aliases := typedAliases(q.Columns()) + for i, order := range orders { + rewriter := typedAliasRewriter{aliases: aliases} + resolved := rewriter.expression(order.Expression()) + if order.Descending() != scanOrders[i].Descending() || !typedSameExpression(resolved, scanOrders[i].Expression()) { + return refuse("ORDER BY differs from the scan order") + } + } + return nil +} + +// typedAliases maps each select alias to the expression it names. +func typedAliases(columns []dal.Column) map[string]dal.Expression { + aliases := make(map[string]dal.Expression, len(columns)) + for _, column := range columns { + if column.Alias != "" { + aliases[column.Alias] = column.Expression + } + } + return aliases +} + // typedCompiler carries the state of one compileTypedSQL call. type typedCompiler struct { dialect typedDialect @@ -221,12 +299,7 @@ func (c *typedCompiler) query(q dal.StructuredQuery) (string, []any, error) { if err != nil { return "", nil, err } - aliases := make(map[string]dal.Expression, len(columns)) - for _, column := range columns { - if column.Alias != "" { - aliases[column.Alias] = column.Expression - } - } + aliases := typedAliases(columns) var text strings.Builder var args []any @@ -904,6 +977,9 @@ func (c *typedCompiler) binary(expression dal.BinaryExpression) (string, []any, } args := slices.Concat(leftArgs, rightArgs) if expression.Operator == dal.Divide { + if err := c.checkDivideOrder(left, right); err != nil { + return "", nil, err + } quotient := c.dialect.divide(left, right) if err := c.checkFragment(quotient, 0, left, right); err != nil { return "", nil, err @@ -913,6 +989,33 @@ func (c *typedCompiler) binary(expression dal.BinaryExpression) (string, []any, return "(" + left + " " + string(expression.Operator) + " " + right + ")", args, nil } +// typedProbeLeft and typedProbeRight are two operands that differ in text, carry +// one marker each, and neither holds the other. They never reach a statement; +// checkDivideOrder hands them to the dialect. +const ( + typedProbeLeft = "(? + 0)" + typedProbeRight = "(0 + ?)" +) + +// checkDivideOrder closes the one gap in operand rule 1. checkTypedFragment sees +// text, so when both operands of a division carry a marker and render as the same +// text, as in (a + 1) / (a + 2), a dialect that wrote the right operand first +// would pass it and the statement would compute the inverse quotient. For that +// case the compiler asks the dialect to divide two probe operands that differ and +// checks the order on them. An operand without a marker carries no argument, so +// a division with one is not probed and the dialect keeps its freedom to repeat +// or move it. +func (c *typedCompiler) checkDivideOrder(left, right string) error { + quote := c.dialect.placeholderStyle().IdentQuote + if countTypedMarkers(left, quote) == 0 || countTypedMarkers(right, quote) == 0 { + return nil + } + if err := c.checkFragment(c.dialect.divide(typedProbeLeft, typedProbeRight), 0, typedProbeLeft, typedProbeRight); err != nil { + return fmt.Errorf("divide, asked to divide two different operands that carry a value: %w", err) + } + return nil +} + func (c *typedCompiler) condition(condition dal.Condition) (string, []any, error) { switch cond := condition.(type) { case dal.Comparison: diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go index 22a5b2d..b2fd2a4 100644 --- a/typed_sql_errors_test.go +++ b/typed_sql_errors_test.go @@ -38,6 +38,12 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) joinedTo := func(base dal.RecordsetSource, joined dal.RecordsetSource) dal.StructuredQuery { return dal.From(base).Join(dal.NewJoinedSource(joined, dal.JoinInner, customerJoin)).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("c", "Name"), "")) } + // leafOf is the one statement over a scan-bounded source that compiles: the + // scan restated as ORDER BY and LIMIT. Each refusal below changes one thing. + leafOf := func(source dal.CollectionRef) dal.IQueryBuilder { + return dal.From(source).NewQuery().OrderBy(source.ScanOrders()...).Limit(source.ScanLimit()) + } + const restated = "a scan-bounded source compiles only when the statement restates its scan" cases := []struct { name string @@ -125,6 +131,32 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) }, {"named-database base source in a join", joinedTo(dal.NewDatabaseCollectionRef("db1", "", "Invoice", "i"), plainCustomer), nil, "from: join_plan: a source in a named database"}, {"named-database joined source", joinedTo(dal.NewRootCollectionRef("Invoice", "i"), dal.NewDatabaseCollectionRef("db2", "", "Customer", "c")), nil, "from.joins[0].from: join_plan: a source in a named database"}, + // A scan on a lone source is "take the bounded rows, then run the rest". + // One SELECT says that only for the leaf query that restates the scan + // (typed_sql_test.go), so each of these, which would otherwise be rendered + // over the whole table, is refused. + {"scan with nothing restated", dal.From(scanned).NewQuery().SelectColumns(), nil, restated}, + {"scan on a pointer source with nothing restated", dal.From(typedTestPtr(scanned)).NewQuery().SelectColumns(), nil, restated}, + {"scan limit with nothing restated", dal.From(scanLimitOnly).NewQuery().SelectColumns(), nil, "LIMIT"}, + {"scan order without a limit, nothing restated", dal.From(scanOrderOnly).NewQuery().SelectColumns(), nil, "ORDER BY"}, + {"scan order without a limit, with a WHERE", leafOf(scanOrderOnly).Where(typedTestEq(typedTestField("Country"), "FR")).SelectColumns(), nil, "WHERE"}, + {"scan with a WHERE", leafOf(scanned).Where(typedTestEq(typedTestField("BillingCountry"), "FR")).SelectColumns(), nil, "WHERE"}, + {"scan with a different ORDER BY", dal.From(scanned).NewQuery().OrderBy(dal.Ascending(typedTestField("Name"))).Limit(10).SelectColumns(), nil, "ORDER BY"}, + {"scan with the same field in the other direction", dal.From(scanned).NewQuery().OrderBy(dal.Ascending(typedTestField("Total"))).Limit(10).SelectColumns(), nil, "ORDER BY"}, + {"scan with the same field under another source qualifier", dal.From(scanned).NewQuery().OrderBy(dal.Descending(typedTestQualified("i", "Total"))).Limit(10).SelectColumns(), nil, "ORDER BY"}, + {"scan with a further ORDER BY item", dal.From(scanned).NewQuery().OrderBy(dal.Descending(typedTestField("Total")), dal.Ascending(typedTestField("Name"))).Limit(10).SelectColumns(), nil, "ORDER BY"}, + {"scan limit with an ORDER BY the scan did not ask for", dal.From(scanLimitOnly).NewQuery().OrderBy(dal.Ascending(typedTestField("Total"))).Limit(10).SelectColumns(), nil, "ORDER BY"}, + { + // ORDER BY Total reads the output column here, which is Price * 2, so the + // statement no longer sorts the scan's own column. + "scan whose ORDER BY names an output alias of another expression", + leafOf(scanned).SelectColumns(typedTestColumn(dal.Binary(typedTestField("Price"), dal.Multiply, typedTestConst(2)), "Total")), + nil, "ORDER BY", + }, + {"scan with a larger LIMIT", leafOf(scanned).Limit(11).SelectColumns(), nil, "LIMIT"}, + {"scan with an OFFSET", leafOf(scanned).Offset(1).SelectColumns(), nil, "OFFSET"}, + {"scan with an aggregate", dal.From(scanned).NewQuery().Limit(10).SelectColumns(dal.CountAs(typedTestField("InvoiceId"), "n")), nil, "aggregation"}, + {"scan with a GROUP BY", leafOf(scanned).GroupBy(typedTestField("Total")).SelectColumns(), nil, "aggregation"}, {"the document identity has no column to read", album().SelectColumns(typedTestColumn(dal.DocumentID(), "")), nil, "dal.DocumentID()"}, {"the document identity in WHERE", album().Where(dal.NewComparison(dal.DocumentID(), dal.Equal, typedTestConst("x"))).SelectColumns(), nil, "dal.DocumentID()"}, {"the document identity in ORDER BY", album().OrderBy(dal.Ascending(dal.DocumentID())).SelectColumns(), nil, "dal.DocumentID()"}, @@ -200,6 +232,39 @@ func TestCompileTypedSQLRejectsInvalidQueriesWithoutClaimingUnsupported(t *testi } } +// TestCompileTypedSQLErrorsCarryNoConstant: DALgo's grouping validation writes the +// query's own expressions into its message, constants included. A server logs +// compile errors, so the compiler reports the rule that failed and drops that +// text; no value reaches the SQL text or an error message. +func TestCompileTypedSQLErrorsCarryNoConstant(t *testing.T) { + album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } + cases := []struct { + name string + query dal.StructuredQuery + }{ + { + "a selected expression that is not grouped", + album().GroupBy(typedTestField("a")).SelectColumns( + typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestConst("hunter2-secret")), "x")), + }, + { + "an IN list in HAVING, which core cannot match to a group key", + album().GroupBy(typedTestField("a")). + Having(dal.NewComparison(typedTestField("a"), dal.In, dal.NewArray([]string{"hunter2-secret", "other-secret"}))). + SelectColumns(typedTestColumn(typedTestField("a"), "")), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectTypedInvalid(t, tc.query, nil, typedCatalogFacts{}, "invalid aggregation") + _, _, err := compileTypedSQL(tc.query, newFakeTypedDialect(), typedCatalogFacts{}) + if strings.Contains(err.Error(), "secret") { + t.Fatalf("error %q carries a constant of the query", err) + } + }) + } +} + func TestCompileTypedSQLWithoutDialect(t *testing.T) { text, args, err := compileTypedSQL(typedTestFrom("Album", "").NewQuery().SelectColumns(), nil, typedCatalogFacts{}) if err == nil || !strings.Contains(err.Error(), "requires a dialect") || text != "" || args != nil { @@ -265,6 +330,11 @@ func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { dividedValues := album().SelectColumns(typedTestColumn(dal.Binary( dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, dal.Binary(typedTestField("b"), dal.Subtract, typedTestConst(2))), "r")) + // (a + 1) / (a + 2): both operands carry a value and render as the same text, + // ("a" + ?::bigint), so no search of the fragment can tell which is which. + dividedSameText := album().SelectColumns(typedTestColumn(dal.Binary( + dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, + dal.Binary(typedTestField("a"), dal.Add, typedTestConst(2))), "r")) // a / b: neither operand carries a value. dividedFields := album().SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Divide, typedTestField("b")), "r")) sumOfProduct := album().SelectColumns(dal.SumAs(dal.Binary(typedTestField("a"), dal.Multiply, typedTestConst(2)), "s")) @@ -285,6 +355,13 @@ func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { }, dividedValues, "verbatim and in argument order", }, + { + "divide renders the right operand before the left, both written the same", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(" + right + " / " + left + ")" } + }, + dividedSameText, "verbatim and in argument order", + }, { "divide renders the left operand twice", func(d *fakeTypedDialect) { @@ -424,6 +501,21 @@ func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { wantSQL: `SELECT (CASE WHEN "b" = 0 THEN NULL ELSE "a" / "b" END) AS "r" FROM "Album"`, }.run(t) }) + t.Run("a dialect may repeat the operand that carries no value beside one that does", func(t *testing.T) { + // (a + 1) / b: the probe is for two operands that both carry a value, so the + // CASE form is not asked to repeat a marker it may not repeat. + dialect := newFakeTypedDialect() + dialect.divideOverride = func(left, right string) string { + return "(CASE WHEN " + right + " = 0 THEN NULL ELSE " + left + " / " + right + " END)" + } + typedGolden{ + dialect: dialect, + query: album().SelectColumns(typedTestColumn(dal.Binary( + dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, typedTestField("b")), "r")), + wantSQL: `SELECT (CASE WHEN "b" = 0 THEN NULL ELSE ("a" + $1::bigint) / "b" END) AS "r" FROM "Album"`, + wantArgs: []any{1}, + }.run(t) + }) t.Run("a dialect that wraps operands without changing them is accepted", func(t *testing.T) { typedGolden{ query: dividedValues, diff --git a/typed_sql_property_test.go b/typed_sql_property_test.go index 87363a4..e641713 100644 --- a/typed_sql_property_test.go +++ b/typed_sql_property_test.go @@ -2,8 +2,10 @@ package dalgo2sql import ( "fmt" + "math" "math/rand/v2" "reflect" + "strconv" "strings" "testing" "time" @@ -11,6 +13,14 @@ import ( "github.com/dal-go/dalgo/dal" ) +// typedProofReporter is what a proof needs of a *testing.T. A test can pass its +// own reporter to run a proof against a dialect that must fail it, and read the +// failure instead of failing itself. +type typedProofReporter interface { + Helper() + Fatalf(format string, args ...any) +} + // typedDraw is one random assignment of values to every constant position of // typedPropertyQuery. Types are fixed per position because a dialect may type a // placeholder by the Go type of its value; only the values themselves vary. @@ -24,28 +34,85 @@ type typedDraw struct { limit, offset int } -// A marker that cannot occur in the compiler's own text, plus characters that -// would matter if a value were ever spliced into SQL. +// typedValuePrefix starts every hostile string the draws build. It cannot occur +// in the compiler's own text, so finding it in the SQL means a value was written +// into it. +const typedValuePrefix = "§value-" + +// typedHostileAlphabet holds characters that would matter if a value were ever +// spliced into SQL. const typedHostileAlphabet = `'"?$;-\()` + "\n" +// drawTypedValues draws every constant of typedPropertyQuery. The Go type stays +// fixed per position, because a dialect may type a placeholder by the Go type of +// its value; the values themselves are drawn from every class a dialect could +// wrongly tell apart by content: integers that are zero, negative, small and +// beyond 32 bits; floats that are whole, zero, negative zero, negative and +// fractional; strings that are empty, digits only, date-looking, and hostile. +// NaN stays out: reflect.DeepEqual on the arguments would fail on it. func drawTypedValues(r *rand.Rand) typedDraw { - text := func() string { + hostile := func() string { var b strings.Builder - b.WriteString("§value-") + b.WriteString(typedValuePrefix) for range r.IntN(12) { b.WriteByte(typedHostileAlphabet[r.IntN(len(typedHostileAlphabet))]) b.WriteByte(byte('a' + r.IntN(26))) } return b.String() } + integer := func() int { + switch r.IntN(6) { + case 0: + return 0 + case 1: + return r.IntN(1 << 30) + case 2: + return -r.IntN(1 << 30) + case 3: + return int(r.Int64()) // beyond 32 bits + case 4: + return -int(r.Int64()) + } + return r.IntN(5) + } + float := func() float64 { + switch r.IntN(7) { + case 0: + return 0 + case 1: + return math.Copysign(0, -1) + case 2: + return float64(r.IntN(1000)) // whole + case 3: + return -float64(r.IntN(1000)) - 1 // negative whole + case 4: + return float64(r.Int64()) // whole, beyond 32 bits + case 5: + return r.Float64() // a fraction + } + return r.NormFloat64() * 1e6 + } + text := func() string { + switch r.IntN(6) { + case 0: + return "" + case 1: + return strconv.Itoa(r.IntN(1000000)) // digits only + case 2: + return time.Unix(r.Int64N(4e9), 0).UTC().Format("2006-01-02") + case 3: + return time.Unix(r.Int64N(4e9), 0).UTC().Format(time.RFC3339) + } + return hostile() + } blob := make([]byte, r.IntN(8)) for i := range blob { blob[i] = byte(r.IntN(256)) } return typedDraw{ - i1: r.IntN(1 << 30), i2: -r.IntN(1 << 30), i3: r.IntN(5), i4: r.IntN(1 << 20), - f1: r.NormFloat64() * 1e6, f2: r.Float64(), - s1: text(), s2: text(), s3: text(), + i1: integer(), i2: integer(), i3: integer(), i4: integer(), + f1: float(), f2: float(), + s1: hostile(), s2: text(), s3: text(), flag: r.IntN(2) == 0, stamp: time.Unix(r.Int64N(4e9), r.Int64N(1e9)).UTC(), blob: blob, @@ -79,7 +146,7 @@ func typedPropertyQuery(d typedDraw) dal.StructuredQuery { } // typedBoundArgument is the argument dialect sends beside the text for value. -func typedBoundArgument(t *testing.T, dialect typedDialect, value any) any { +func typedBoundArgument(t typedProofReporter, dialect typedDialect, value any) any { t.Helper() _, arg, err := dialect.bind(value) if err != nil { @@ -90,7 +157,7 @@ func typedBoundArgument(t *testing.T, dialect typedDialect, value any) any { // wantArgs lists the arguments typedPropertyQuery binds, in text order, as // dialect spells them. -func (d typedDraw) wantArgs(t *testing.T, dialect typedDialect) []any { +func (d typedDraw) wantArgs(t typedProofReporter, dialect typedDialect) []any { t.Helper() bound := func(value any) any { return typedBoundArgument(t, dialect, value) } _, page := dialect.limitOffset(d.limit, d.offset) @@ -106,7 +173,7 @@ func (d typedDraw) wantArgs(t *testing.T, dialect typedDialect) []any { // by a byte. It takes the dialect, so the PostgreSQL dialect runs the same draws // as the fake one; a dialect that typed a placeholder by the content of a value // (as opposed to its Go type) fails here. -func assertTypedTextIgnoresValues(t *testing.T, dialect typedDialect) { +func assertTypedTextIgnoresValues(t typedProofReporter, dialect typedDialect) { t.Helper() style := dialect.placeholderStyle() r := rand.New(rand.NewPCG(20261004, 7)) @@ -126,7 +193,9 @@ func assertTypedTextIgnoresValues(t *testing.T, dialect typedDialect) { t.Fatalf("draw %d: args = %#v, want %#v", i, args, want) } for _, value := range []string{draw.s1, draw.s2, draw.s3} { - if strings.Contains(text, value) { + // Digit-only and date-looking strings can occur in the text for their own + // sake (a placeholder number); the hostile ones carry the prefix. + if strings.HasPrefix(value, typedValuePrefix) && strings.Contains(text, value) { t.Fatalf("draw %d: value %q reached the SQL text %q", i, value, text) } } @@ -149,7 +218,7 @@ func assertTypedTextIgnoresValues(t *testing.T, dialect typedDialect) { // fixed strings, "ORDER BY 2" when the constants are equal and the inline bound // expression when they are not. Each constant is drawn independently, from a // range small enough to meet equality often. -func assertTypedTextHasTwoShapesForTwoConstants(t *testing.T, dialect typedDialect) { +func assertTypedTextHasTwoShapesForTwoConstants(t typedProofReporter, dialect typedDialect) { t.Helper() compile := func(selected, ordered int) (string, []any) { q := typedTestFrom("Album", "").NewQuery(). @@ -281,3 +350,109 @@ func TestCompileTypedSQLListLengthAndZeroLimitChangeStructureNotValues(t *testin t.Fatalf("a zero limit compiled to the same text %q", a) } } + +// typedProofRecorder stands in for a *testing.T: it keeps the first failure a +// proof reports and stops the proof, as t.Fatalf does. +type typedProofRecorder struct{ failure string } + +type typedProofStopped struct{} + +func (r *typedProofRecorder) Helper() {} + +func (r *typedProofRecorder) Fatalf(format string, args ...any) { + r.failure = fmt.Sprintf(format, args...) + panic(typedProofStopped{}) +} + +// failureOfTypedProof runs proof and returns the failure it reported, or "" when +// it passed. +func failureOfTypedProof(proof func(typedProofReporter)) (failure string) { + recorder := &typedProofRecorder{} + defer func() { + if recovered := recover(); recovered != nil { + if _, stopped := recovered.(typedProofStopped); !stopped { + panic(recovered) + } + failure = recorder.failure + } + }() + proof(recorder) + return "" +} + +// TestCompileTypedSQLPropertyCatchesADialectThatTypesByContent is the proof of the +// proof: each dialect below picks its cast from what a value holds, the mistake +// the contract names (bigint for a whole number, numeric for a fraction), and the +// draws must contain a pair of values that tells each of them apart. The honest +// dialect passes the same proof (TestCompileTypedSQLTextDoesNotDependOnValues). +func TestCompileTypedSQLPropertyCatchesADialectThatTypesByContent(t *testing.T) { + isDigits := func(s string) bool { + _, err := strconv.ParseUint(s, 10, 64) + return err == nil && !strings.HasPrefix(s, "+") + } + cases := map[string]func(value any) string{ + "bigint for a whole float, numeric for a fraction": func(value any) string { + if f, ok := value.(float64); ok { + if f == math.Trunc(f) { + return "?::bigint" + } + return "?::numeric" + } + return "" + }, + "smallint for an integer zero": func(value any) string { + if n, ok := value.(int); ok && n == 0 { + return "?::smallint" + } + return "" + }, + "integer for an integer that fits 32 bits": func(value any) string { + if n, ok := value.(int); ok && n >= math.MinInt32 && n <= math.MaxInt32 { + return "?::integer" + } + return "" + }, + "smallint for a negative integer": func(value any) string { + if n, ok := value.(int); ok && n < 0 { + return "?::smallint" + } + return "" + }, + "bigint for a string of digits": func(value any) string { + if s, ok := value.(string); ok && isDigits(s) { + return "?::bigint" + } + return "" + }, + "date for a string that looks like a date": func(value any) string { + if s, ok := value.(string); ok && len(s) >= 10 { + if _, err := time.Parse("2006-01-02", s[:10]); err == nil { + return "?::date" + } + } + return "" + }, + "text for the empty string": func(value any) string { + if s, ok := value.(string); ok && s == "" { + return "?::text" + } + return "" + }, + } + for name, pick := range cases { + t.Run(name, func(t *testing.T) { + honest := newFakeTypedDialect() + dialect := newFakeTypedDialect() + dialect.bindOverride = func(value any) (string, any, error) { + if marker := pick(value); marker != "" { + return marker, value, nil + } + return honest.bind(value) + } + failure := failureOfTypedProof(func(r typedProofReporter) { assertTypedTextIgnoresValues(r, dialect) }) + if !strings.Contains(failure, "changed the SQL text") { + t.Fatalf("assertTypedTextIgnoresValues reported %q for a dialect that types by content, want it to fail on the changed text", failure) + } + }) + } +} diff --git a/typed_sql_test.go b/typed_sql_test.go index 4629a41..382a184 100644 --- a/typed_sql_test.go +++ b/typed_sql_test.go @@ -607,24 +607,75 @@ func TestCompileTypedSQLQualifiersWithQuoteCharacters(t *testing.T) { } func TestCompileTypedSQLScanBoundedSingleSource(t *testing.T) { - // A bound on a lone source is rendered as the statement's own ORDER BY and - // LIMIT, which is how DALgo's generic join asks a relation for its rows: it - // sends each bounded relation as a single-source query that restates the scan - // while the source still carries it. Refusing that leaf would leave a - // dialect without a legacy emitter unable to run DALgo's generic join at all. + // A scan on a lone source means: take the bounded, ordered rows first, then + // apply the rest of the statement. One SELECT can say that only when the + // statement is the bounded read itself, which is the leaf query DALgo's + // generic join and federated executor send: the same ORDER BY as the scan and + // a LIMIT within it, nothing else. Every other statement over a scan-bounded + // source is refused (see the refusal table in typed_sql_errors_test.go). scanned := dal.NewRootCollectionRef("Invoice", "i").WithScan(10, dal.Descending(typedTestField("Total"))) - t.Run("the leaf query the generic join sends", func(t *testing.T) { - typedGolden{ - query: dal.From(scanned).NewQuery().OrderBy(scanned.ScanOrders()...).Limit(scanned.ScanLimit()).SelectColumns(), + orderOnly := dal.NewRootCollectionRef("Customer", "c").WithScan(0, dal.Ascending(typedTestField("Name"))) + limitOnly := dal.NewRootCollectionRef("Invoice", "i").WithScan(10) + leaf := func(source dal.RecordsetSource) dal.IQueryBuilder { + c := source.(dal.CollectionRef) + return dal.From(source).NewQuery().OrderBy(c.ScanOrders()...).Limit(c.ScanLimit()) + } + runTypedGoldens(t, []typedGolden{ + { + name: "the leaf query the generic join sends", + query: leaf(scanned).SelectColumns(), wantSQL: `SELECT * FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, wantArgs: []any{10}, - }.run(t) - }) - t.Run("a pointer source", func(t *testing.T) { - typedGolden{ - query: dal.From(typedTestPtr(scanned)).NewQuery().SelectColumns(), + }, + { + name: "the leaf query over a pointer source", + query: dal.From(typedTestPtr(scanned)).NewQuery().OrderBy(scanned.ScanOrders()...).Limit(10).SelectColumns(), + wantSQL: `SELECT * FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, + wantArgs: []any{10}, + }, + { + name: "a statement limit below the scan limit", + query: leaf(scanned).Limit(4).SelectColumns(), + wantSQL: `SELECT * FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, + wantArgs: []any{4}, + }, + { + name: "the projection stays free", + query: leaf(scanned).SelectColumns(typedTestColumn(typedTestField("Total"), "")), + wantSQL: `SELECT "Total" FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, + wantArgs: []any{10}, + }, + { + name: "an alias that names the scan field after itself restates the same order", + query: leaf(scanned).SelectColumns(typedTestColumn(typedTestField("Total"), "Total")), + wantSQL: `SELECT "Total" AS "Total" FROM "Invoice" AS "i" ORDER BY "Total" DESC NULLS LAST LIMIT $1`, + wantArgs: []any{10}, + }, + { + name: "a scan order without a scan limit", + query: leaf(orderOnly).SelectColumns(), + wantSQL: `SELECT * FROM "Customer" AS "c" ORDER BY "Name" ASC NULLS FIRST`, + }, + { + name: "a scan order without a scan limit, under a statement limit", + query: leaf(orderOnly).Limit(3).SelectColumns(), + wantSQL: `SELECT * FROM "Customer" AS "c" ORDER BY "Name" ASC NULLS FIRST LIMIT $1`, + wantArgs: []any{3}, + }, + { + name: "a scan limit without a scan order", + query: leaf(limitOnly).SelectColumns(), + wantSQL: `SELECT * FROM "Invoice" AS "i" LIMIT $1`, + wantArgs: []any{10}, + }, + { + // The federated executor routes a source in a named database to that + // database before it sends the leaf, so the compiler, which is handed + // the connection of that database, renders the table by its own name. + name: "a lone source in a named database", + query: dal.From(dal.NewDatabaseCollectionRef("db1", "", "Invoice", "i")).NewQuery().SelectColumns(), wantSQL: `SELECT * FROM "Invoice" AS "i"`, - }.run(t) + }, }) } From d59eee58af3886178f227f2c4cdb8a315b52c71e Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 22:27:45 +0100 Subject: [PATCH 4/6] fix(typed-sql): refuse an ORDER BY name a select-list output would capture, and the money option An ORDER BY alias rewritten to a bare column name is read by PostgreSQL as an output column first, so when another select alias reuses that name the server sorts by the wrong column (and a scan restated through such an alias passes the scan check). typedCheckOrderByName refuses an item whose written bare name any other select-list output shares, unaliased expressions included. GROUP BY and HAVING are pinned safe by tests: GROUP BY prefers an input column and HAVING cannot see outputs. Also: refuse a query carrying the DTQL money option instead of dropping it; reword the fixed aggregation message so it does not claim which rule failed and points at dal.ValidateAggregation; correct the divide probe test and comment to the (1.0 / right * left) case; record the name-resolution assumption on typedDialect. Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 9 +++ typed_sql.go | 125 +++++++++++++++++++++++++++++++-------- typed_sql_errors_test.go | 77 +++++++++++++++++++++++- typed_sql_test.go | 95 +++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 28 deletions(-) diff --git a/typed_dialect.go b/typed_dialect.go index 8d8ac9c..b0bd879 100644 --- a/typed_dialect.go +++ b/typed_dialect.go @@ -59,6 +59,15 @@ import ( // the compiler can place them next to any operator. // - An error a dialect returns must not quote a value: the compiler passes it // on, and a server logs it. Name the type or the rule, never the content. +// - Name resolution (an assumption of the compiler, not checked): a statement +// over one source writes bare column names, and the compiler relies on the +// PostgreSQL reading of them. A bare name that stands alone in ORDER BY is a +// select-list output first, so the compiler refuses an ORDER BY item whose +// written name another output shares (typedCheckOrderByName). In GROUP BY a +// bare name is an input column before it is an output, and HAVING cannot see +// outputs at all, so both write the input column as it is. A dialect for an +// engine that reads a select alias before an input column in GROUP BY or +// HAVING needs that check extended before it is added. // // The interface stays unexported until three dialects exist. type typedDialect interface { diff --git a/typed_sql.go b/typed_sql.go index aca993b..87dd07b 100644 --- a/typed_sql.go +++ b/typed_sql.go @@ -53,15 +53,17 @@ func typedUnsupported(format string, args ...any) error { // assertTypedTextIgnoresValues and assertTypedTextHasTwoShapesForTwoConstants // (typed_sql_property_test.go) check this for any dialect. // -// Error messages follow the same rule: they quote identifiers and name the rule -// that failed, never a constant. DALgo's grouping validation words its message -// with the query's own expressions, so it is replaced (errTypedInvalidAggregation); -// an error a dialect returns is the dialect's to keep clean (see typedDialect). +// Error messages follow the same rule: they quote identifiers and name what was +// refused, never a constant. DALgo's aggregation validation words its message +// with the query's own expressions, so it is replaced by a fixed message that +// points at dal.ValidateAggregation (errTypedInvalidAggregation); an error a +// dialect returns is the dialect's to keep clean (see typedDialect). // // Anything the engine cannot run faithfully returns an error matching // dal.ErrNotSupported rather than an approximation: an unrecognised node, a -// source option the statement cannot honour (see validateTypedJoinSources and -// validateTypedScanRestated), a reference the server could not match. DALgo's +// query option the statement cannot honour (the DTQL money option, a source's +// scan or database: see validateTypedJoinSources and validateTypedScanRestated), +// a reference the server could not match (typedCheckOrderByName). DALgo's // generic engine remains the place for those queries. func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts) (string, []any, error) { if dialect == nil { @@ -76,6 +78,13 @@ func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCat if dal.HasSubquery(q) { return "", nil, typedUnsupported("subqueries run in the generic engine, not in one statement") } + // A DTQL query can ask for exact decimal results (dtql/query.go, Money()). + // DALgo computes those in its federated engine, in decimal text with half-even + // rounding; the statement would compute SUM, AVG and division in double + // precision, so honouring the option here would be approximating it. + if m, ok := q.(interface{ Money() *dal.MoneyConfig }); ok && m.Money() != nil { + return "", nil, typedUnsupported("the money option (exact decimal results) is computed by DALgo's federated engine, not in one statement") + } if q.Limit() < 0 || q.Offset() < 0 { return "", nil, errors.New("limit and offset must be non-negative") } @@ -109,13 +118,17 @@ func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCat return text, args, nil } -// errTypedInvalidAggregation is the whole of what the compiler says when DALgo's -// grouping validation rejects a query. That validation words its message with the -// query's own expressions, constants included (selected expression "(a + +// errTypedInvalidAggregation is the whole of what the compiler says when +// dal.ValidateAggregation rejects a query. That validation words its message with +// the query's own expressions, constants included (selected expression "(a + // 'secret')" is neither aggregated nor present in GROUP BY), and a server logs -// compile errors, so the message is dropped and the rule that failed is named. -var errTypedInvalidAggregation = errors.New("invalid aggregation: every selected, HAVING and ORDER BY expression must be an aggregate, a GROUP BY expression or a select alias, " + - "select aliases must be unique and aggregates may not nest; the detail is left out because it would quote the query's constants") +// compile errors, so the message is dropped. It rejects for more reasons than +// grouping (SUM(*), COUNT(DISTINCT *), DISTINCT on MIN or MAX, a wildcard in an +// aggregate select, an unsupported aggregate or condition, a nil expression), so +// the replacement does not say which rule failed; it points at the validator, +// which the caller can run on the query to be told. +var errTypedInvalidAggregation = errors.New("invalid aggregation: the query breaks DALgo's aggregation rules (grouping of selected, HAVING and ORDER BY expressions, alias uniqueness, or an aggregate form DALgo does not accept); " + + "dal.ValidateAggregation(q) names the rule, and its text is left out here because it can quote the query's constants") func validateTypedAggregation(q dal.StructuredQuery, dialect typedDialect) error { if dal.ValidateAggregation(q) != nil { @@ -191,11 +204,18 @@ func validateTypedJoinSources(from dal.FromSource, path string) error { // ErrNotSupported and DALgo's generic engine runs it. // // The statement restates the scan when ORDER BY equals the scan's orders item -// by item (after select aliases are resolved, as the compiled text resolves -// them), in the same direction, and, if the scan has a limit, the statement's -// LIMIT is set and no larger. The projection is free: a projection after a limit -// reads the same rows. A statement limit below the scan's is the federated -// executor's own tightening and is accepted. +// by item (after select aliases are resolved, as orderBy resolves them), in the +// same direction, and, if the scan has a limit, the statement's LIMIT is set and +// no larger. The projection is free: a projection after a limit reads the same +// rows. A statement limit below the scan's is the federated executor's own +// tightening and is accepted. +// +// What is compared is the expression orderBy writes, which the server reads as +// that expression only if no select-list output shares a bare written name with +// it. This check cannot see that, because it runs before the select list is +// rendered; typedCheckOrderByName runs on the same item when the statement is +// compiled and refuses such a statement, so a statement that passes both sorts by +// the scan's own column. // // Database() is not checked. A lone source in a named database is right only // because the federated executor already routed the leaf to that database's @@ -277,6 +297,10 @@ type typedSelectItem struct { expression dal.Expression sql string args []any + // name is the output column name the server gives the item: the alias, else + // the column's own name, else the text the compiler wrote after AS. It is empty + // for the bare "*", whose outputs are the input columns themselves. + name string } // query assembles the statement. FROM is rendered first because it fixes which @@ -389,7 +413,7 @@ func (c *typedCompiler) expandWildcard(wildcard *wildcardProjectionPlan) ([]type if err != nil { return nil, err } - items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted}) + items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted, name: column.Name}) } return items, nil } @@ -406,7 +430,8 @@ func (c *typedCompiler) selectItem(column dal.Column) (typedSelectItem, error) { item := typedSelectItem{expression: column.Expression, sql: sql, args: args} name := column.Alias if name == "" { - if _, isField := column.Expression.(dal.FieldRef); isField { + if field, isField := column.Expression.(dal.FieldRef); isField { + item.name = field.Name() return item, nil } if len(args) != 0 { @@ -419,6 +444,7 @@ func (c *typedCompiler) selectItem(column dal.Column) (typedSelectItem, error) { return typedSelectItem{}, err } item.sql += " AS " + quoted + item.name = name return item, nil } @@ -480,6 +506,10 @@ func (c *typedCompiler) having(condition dal.Condition, aliases map[string]dal.E // expression has no position to refer to, and bound inline it would be a fresh // parameter that the server cannot match to GROUP BY, so it is refused as HAVING // refuses it. Without a GROUP BY the inline form is valid and stays. +// +// An item that rewrites to a bare column name is checked by +// typedCheckOrderByName: the server reads a bare ORDER BY name as a select-list +// output first, so the written name must not be one. func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelectItem, aliases map[string]dal.Expression, grouped bool) (string, []any, error) { if len(orders) == 0 { return "", nil, nil @@ -495,6 +525,9 @@ func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelec if err != nil { return "", nil, fmt.Errorf("orderBy %d: %w", i, err) } + if err := typedCheckOrderByName(expression, items); err != nil { + return "", nil, fmt.Errorf("orderBy %d: %w", i, err) + } if grouped && rewriter.groupedConstant != "" && len(values) != 0 { return "", nil, fmt.Errorf("orderBy %d: %w", i, typedUnsupported("ORDER BY refers to %q, a grouped expression carrying a constant that the server cannot match to GROUP BY", rewriter.groupedConstant)) } @@ -508,6 +541,42 @@ func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelec return " ORDER BY " + strings.Join(parts, ", "), args, nil } +// typedCheckOrderByName refuses an ORDER BY item whose written form is a bare +// column name that the server would read as a select-list output of that name +// when that output is another expression. PostgreSQL matches a name that stands +// alone in ORDER BY against output columns first and against input columns only +// when none has the name; a qualified name, and a name inside an expression, are +// always input columns. Select aliases are rewritten to the aliased expression +// (typedAliasRewriter), so `SELECT Total AS a, Name AS Total ORDER BY a` would be +// written ORDER BY "Total" and sort by Name, and, in a grouped query, +// `SELECT a AS x, COUNT(b) AS a GROUP BY a ORDER BY x` would sort by the count +// where DALgo sorts by the group key. Every output of that name must be the +// column itself, so any other one, not just the first, refuses the item. +// +// The name an output has is typedSelectItem.name, so an unaliased expression, +// which the compiler names by its text, counts too. A qualified field cannot be +// captured, and a join statement writes no bare name (c.expr refuses it first), +// so a source on the item or on the output needs no comparison here. +// +// The refusal sends the query to DALgo's generic engine. Writing the column +// qualified by the base source would keep it native; refusing is the narrower +// change, and the shape is rare. +func typedCheckOrderByName(expression dal.Expression, items []typedSelectItem) error { + field, ok := expression.(dal.FieldRef) + if !ok || field.Source() != "" { + return nil + } + for _, item := range items { + if item.name != field.Name() { + continue + } + if output, isField := item.expression.(dal.FieldRef); !isField || output.Name() != field.Name() { + return typedUnsupported("ORDER BY resolves to column %q, which the server reads as the select-list output of that name, a different expression", field.Name()) + } + } + return nil +} + // byExpression compiles a GROUP BY or ORDER BY expression. // // A constant-only expression neither groups nor orders anything, and the server @@ -999,12 +1068,18 @@ const ( // checkDivideOrder closes the one gap in operand rule 1. checkTypedFragment sees // text, so when both operands of a division carry a marker and render as the same -// text, as in (a + 1) / (a + 2), a dialect that wrote the right operand first -// would pass it and the statement would compute the inverse quotient. For that -// case the compiler asks the dialect to divide two probe operands that differ and -// checks the order on them. An operand without a marker carries no argument, so -// a division with one is not probed and the dialect keeps its freedom to repeat -// or move it. +// text, as in (a + 1) / (a + 2), it cannot tell which operand is which. A dialect +// that places the right operand before the left, in a form whose value depends on +// the position such as (1.0 / right * left), would pass: the fragment is +// byte-identical to the correct (1.0 / left * right), the arguments stay in order, +// the first marker takes the left operand's value, and the statement computes the +// inverse quotient. (A plain (right / left) writes the same bytes as (left / +// right) for equal texts and does no harm there, but the probe refuses it too, +// because divide must be a function of its operand texts alone.) For this case the +// compiler asks the dialect to divide two probe operands that differ and checks +// the order on them. An operand without a marker carries no argument, so a +// division with one is not probed and the dialect keeps its freedom to repeat or +// move it. func (c *typedCompiler) checkDivideOrder(left, right string) error { quote := c.dialect.placeholderStyle().IdentQuote if countTypedMarkers(left, quote) == 0 || countTypedMarkers(right, quote) == 0 { diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go index b2fd2a4..d8a2074 100644 --- a/typed_sql_errors_test.go +++ b/typed_sql_errors_test.go @@ -13,6 +13,15 @@ type fakeTypedCondition struct{} func (fakeTypedCondition) String() string { return "fake" } +// typedMoneyQuery is a query that carries the DTQL money option, as the query +// dtql.Deserialize returns does (dtql/query.go in DALgo). +type typedMoneyQuery struct { + dal.StructuredQuery + money *dal.MoneyConfig +} + +func (q typedMoneyQuery) Money() *dal.MoneyConfig { return q.money } + func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) { album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } whereBy := func(c dal.Condition) dal.StructuredQuery { return album().Where(c).SelectColumns() } @@ -116,6 +125,45 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) SelectColumns(bucketColumns...), nil, "orderBy 1", }, + // PostgreSQL reads a bare ORDER BY name as an output column first. An alias + // that resolves to a bare column is therefore written as that column only + // when no select-list output of that name is another expression; otherwise + // the server would sort by the other output. + { + "ORDER BY alias that resolves to a column another select alias reuses", + album().OrderBy(dal.Descending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "Total")), + nil, `ORDER BY resolves to column "Total"`, + }, + { + "grouped ORDER BY alias of a group key that another select alias reuses", + album().GroupBy(typedTestField("a")).OrderBy(dal.Ascending(typedTestField("x"))). + SelectColumns(typedTestColumn(typedTestField("a"), "x"), dal.CountAs(typedTestField("b"), "a")), + nil, `ORDER BY resolves to column "a"`, + }, + { + // Total DESC is the scan, so the statement looks like the leaf query, but + // the server would sort by Name, the output named Total. + "scan whose ORDER BY alias resolves to the scan column that another alias reuses", + dal.From(scanned).NewQuery().OrderBy(dal.Descending(typedTestField("a"))).Limit(10). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "Total")), + nil, `ORDER BY resolves to column "Total"`, + }, + { + "ORDER BY alias resolving to a column when a later output of that name differs from an earlier one that matches", + album().OrderBy(dal.Ascending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Total"), ""), typedTestColumn(typedTestField("Name"), "Total")), + nil, `ORDER BY resolves to column "Total"`, + }, + { + // An unaliased expression is named by its text (the compiler writes AS + // "(a + b)"), so a column called that is captured the same way. + "ORDER BY a column named like an unaliased select expression", + album().OrderBy(dal.Ascending(typedTestField("(a + b)"))). + SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestField("b")), "")), + nil, `ORDER BY resolves to column "(a + b)"`, + }, + {"query carrying a DTQL money option", typedMoneyQuery{StructuredQuery: album().SelectColumns(), money: &dal.MoneyConfig{MinorUnitScale: 2, DivisionScale: 4, Rounding: "halfEven"}}, nil, "money"}, {"scan-bounded base source in a join", joinedTo(scanned, plainCustomer), nil, "from: join_plan: a scan-bounded source"}, {"scan limit alone on the base source of a join", joinedTo(scanLimitOnly, plainCustomer), nil, "scan-bounded"}, {"scan order alone on a joined source", joinedTo(dal.NewRootCollectionRef("Invoice", "i"), scanOrderOnly), nil, "from.joins[0].from: join_plan: a scan-bounded source"}, @@ -198,6 +246,10 @@ func TestCompileTypedSQLRejectsInvalidQueriesWithoutClaimingUnsupported(t *testi {"negative limit", album().Limit(-1).SelectColumns(), nil, "non-negative"}, {"negative offset", album().Offset(-1).SelectColumns(), nil, "non-negative"}, {"ungrouped column", album().GroupBy(typedTestField("a")).SelectColumns(typedTestColumn(typedTestField("b"), "")), nil, "invalid aggregation"}, + // dal.ValidateAggregation rejects for more reasons than grouping, so the + // message must not claim which rule a query broke; it points at the validator. + {"SUM of a star, which breaks no grouping rule", album().SelectColumns(typedTestColumn(dal.NewAggregate(dal.SUM, false, dal.Star()), "s")), nil, "dal.ValidateAggregation"}, + {"an ungrouped column points at the validator as well", album().GroupBy(typedTestField("a")).SelectColumns(typedTestColumn(typedTestField("b"), "")), nil, "dal.ValidateAggregation"}, {"empty join ON", typedTestFrom("Album", "a").Join(dal.NewJoinedSource(dal.NewRootCollectionRef("Artist", "r"), dal.JoinInner)).NewQuery().SelectColumns(), nil, "join_shape"}, {"unknown source", album().SelectColumns(typedTestColumn(typedTestQualified("zzz", "a"), "")), nil, "unknown source"}, {"empty field name", album().SelectColumns(typedTestColumn(typedTestField(""), "")), nil, "empty"}, @@ -234,8 +286,10 @@ func TestCompileTypedSQLRejectsInvalidQueriesWithoutClaimingUnsupported(t *testi // TestCompileTypedSQLErrorsCarryNoConstant: DALgo's grouping validation writes the // query's own expressions into its message, constants included. A server logs -// compile errors, so the compiler reports the rule that failed and drops that -// text; no value reaches the SQL text or an error message. +// compile errors, so the compiler drops that text and points at +// dal.ValidateAggregation instead (it does not say which rule failed: the +// validation rejects for more reasons than grouping); no value reaches the SQL +// text or an error message. func TestCompileTypedSQLErrorsCarryNoConstant(t *testing.T) { album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } cases := []struct { @@ -332,6 +386,9 @@ func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { dal.Binary(typedTestField("b"), dal.Subtract, typedTestConst(2))), "r")) // (a + 1) / (a + 2): both operands carry a value and render as the same text, // ("a" + ?::bigint), so no search of the fragment can tell which is which. + // Only a fragment whose value depends on the position, such as + // (1.0 / right * left), computes the inverse quotient from the swap; a plain + // (right / left) writes the same bytes as (left / right) here. dividedSameText := album().SelectColumns(typedTestColumn(dal.Binary( dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)), dal.Divide, dal.Binary(typedTestField("a"), dal.Add, typedTestConst(2))), "r")) @@ -356,7 +413,21 @@ func TestCompileTypedSQLRefusesADialectThatBreaksTheOperandRules(t *testing.T) { dividedValues, "verbatim and in argument order", }, { - "divide renders the right operand before the left, both written the same", + // With the operands written the same, (1.0 / right * left) is byte-identical + // to (1.0 / left * right) and the arguments stay [1 2], so only the probe + // can see that the first marker would take the left operand's value and + // the statement would compute (a + 2) / (a + 1). + "divide places the right operand before the left in a form that depends on position, both written the same", + func(d *fakeTypedDialect) { + d.divideOverride = func(left, right string) string { return "(1.0 / " + right + " * " + left + ")" } + }, + dividedSameText, "verbatim and in argument order", + }, + { + // Harmless on this query (the bytes equal the correct fragment), but the + // compiler cannot know that without the operands' text alone deciding it: + // divide must be a function of its operand texts, so the swap is refused. + "divide swaps the operands, both written the same", func(d *fakeTypedDialect) { d.divideOverride = func(left, right string) string { return "(" + right + " / " + left + ")" } }, diff --git a/typed_sql_test.go b/typed_sql_test.go index 382a184..3a2c711 100644 --- a/typed_sql_test.go +++ b/typed_sql_test.go @@ -466,6 +466,93 @@ func TestCompileTypedSQLOrderBy(t *testing.T) { }) } +// TestCompileTypedSQLBareNamesAreReadAsInputColumns covers every place the +// compiler writes a bare, unqualified name into ORDER BY, GROUP BY or HAVING, +// where the server could read it as an output column (a select alias) instead of +// the input column DALgo means. PostgreSQL's rules, from its SELECT reference: +// +// - ORDER BY: a name that stands alone is matched against output columns first, +// and only then against input columns; inside an expression it is an input +// column. So a bare ORDER BY name is the one place a capture can happen, and +// orderBy refuses it (typed_sql_errors_test.go has the refusals). What is +// safe is pinned here: a bare name no output column shares, one every +// same-named output column agrees with, a qualified name, and a name nested +// in an expression. +// - GROUP BY: "in case of ambiguity, a GROUP BY name will be interpreted as an +// input-column name rather than an output column name". DALgo groups by the +// input expression, so the bare name is already the right reading; and when no +// input column has the name, the only output it can reach is one the validation +// required to be grouped as well, so the partition is the same. The compiler +// never rewrites a GROUP BY name to an alias. +// - HAVING: output names are not visible to HAVING at all, so a bare name is an +// input column. +func TestCompileTypedSQLBareNamesAreReadAsInputColumns(t *testing.T) { + invoice := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + runTypedGoldens(t, []typedGolden{ + { + name: "ORDER BY alias of a column no other output shares its name with", + query: invoice().OrderBy(dal.Descending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "n")), + wantSQL: `SELECT "Total" AS "a", "Name" AS "n" FROM "Invoice" ORDER BY "Total" DESC NULLS LAST`, + }, + { + name: "ORDER BY alias of a column that is also selected under its own name", + query: invoice().OrderBy(dal.Ascending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Total"), "")), + wantSQL: `SELECT "Total" AS "a", "Total" FROM "Invoice" ORDER BY "Total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY a selected column under its own name", + query: invoice().OrderBy(dal.Ascending(typedTestField("Total"))). + SelectColumns(typedTestColumn(typedTestField("Total"), ""), typedTestColumn(typedTestField("Name"), "a")), + wantSQL: `SELECT "Total", "Name" AS "a" FROM "Invoice" ORDER BY "Total" ASC NULLS FIRST`, + }, + { + // Qualified, the name is never an output column, whatever the select list says. + name: "ORDER BY a qualified name that another select alias reuses", + query: typedTestFrom("Invoice", "i").NewQuery().OrderBy(dal.Ascending(typedTestQualified("i", "Total"))). + SelectColumns(typedTestColumn(typedTestField("Name"), "Total")), + wantSQL: `SELECT "Name" AS "Total" FROM "Invoice" AS "i" ORDER BY "i"."Total" ASC NULLS FIRST`, + }, + { + // Inside an expression a name is an input column, so the capture the bare + // form would suffer cannot happen and the query stays native. + name: "ORDER BY alias nested in an expression, when another select alias reuses the column's name", + query: invoice().OrderBy(dal.Ascending(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "Total")), + wantSQL: `SELECT "Total" AS "a", "Name" AS "Total" FROM "Invoice" ORDER BY ("Total" + $1::bigint) ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + // In a join every name is qualified, so an alias resolves to a qualified column. + name: "ORDER BY alias in a join, when another select alias reuses the column's name", + query: typedTestFrom("Invoice", "i").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Customer", "c"), dal.JoinInner, typedTestJoinOn("i", "CustomerId", "c", "CustomerId")), + ).NewQuery().OrderBy(dal.Ascending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestQualified("i", "Total"), "a"), typedTestColumn(typedTestQualified("c", "Name"), "Total")), + wantSQL: `SELECT "i"."Total" AS "a", "c"."Name" AS "Total" FROM "Invoice" AS "i" INNER JOIN "Customer" AS "c" ON ("i"."CustomerId" = "c"."CustomerId") ` + + `ORDER BY "i"."Total" ASC NULLS FIRST`, + }, + { + // The group key a is an input column, which GROUP BY prefers to the output + // COUNT(b) AS a; the statement groups by the column, as DALgo does. + name: "GROUP BY a column that another select alias reuses", + query: invoice().GroupBy(typedTestField("a")). + SelectColumns(typedTestColumn(typedTestField("a"), "x"), dal.CountAs(typedTestField("b"), "a")), + wantSQL: `SELECT "a" AS "x", COUNT("b") AS "a" FROM "Invoice" GROUP BY "a"`, + }, + { + // HAVING cannot see the output a, so the rewritten bare "a" is the group key. + name: "HAVING alias of a group key, when another select alias reuses the key's name", + query: invoice().GroupBy(typedTestField("a")). + Having(dal.NewComparison(typedTestField("x"), dal.GreaterThen, typedTestConst(1))). + SelectColumns(typedTestColumn(typedTestField("a"), "x"), dal.CountAs(typedTestField("b"), "a")), + wantSQL: `SELECT "a" AS "x", COUNT("b") AS "a" FROM "Invoice" GROUP BY "a" HAVING "a" > $1::bigint`, + wantArgs: []any{1}, + }, + }) +} + func TestCompileTypedSQLArithmetic(t *testing.T) { album := func(expression dal.Expression) dal.StructuredQuery { return typedTestFrom("Album", "").NewQuery().SelectColumns(typedTestColumn(expression, "r")) @@ -679,6 +766,14 @@ func TestCompileTypedSQLScanBoundedSingleSource(t *testing.T) { }) } +func TestCompileTypedSQLQueryTypeThatCanCarryMoneyButCarriesNone(t *testing.T) { + // The refusal table has the query that does carry the option. + typedGolden{ + query: typedMoneyQuery{StructuredQuery: typedTestFrom("Album", "").NewQuery().SelectColumns()}, + wantSQL: `SELECT * FROM "Album"`, + }.run(t) +} + func TestCompileTypedSQLIdentityFields(t *testing.T) { // dal.ID names a real column and is rendered as one; dal.DocumentID has no // column of its own and is refused (see the unsupported-node table). From 9357733aacdd87e8b63ac491f6336cda4606f5c2 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 22:50:39 +0100 Subject: [PATCH 5/6] fix(typed-sql): compare the quoted names written in the ORDER BY capture guard The guard compared the query's spelling, so a quoteIdent that folds case (the PostgreSQL FoldLower mode) wrote Total and total as one identifier and let a statement through that sorts by the wrong output. It now compares the quoted names the dialect wrote: typedSelectItem keeps the quoted output name and the quoted field name. Tests with a lower-casing fake dialect were red first. Also document what stays on the query's spelling and fail closed (alias lookup, source qualifiers, the scan comparison), and fix the divide comment. Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 26 ++++++++--- typed_dialect_fake_test.go | 17 +++++++ typed_sql.go | 84 ++++++++++++++++++++++----------- typed_sql_errors_test.go | 61 ++++++++++++++++++++++++ typed_sql_test.go | 96 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 33 deletions(-) diff --git a/typed_dialect.go b/typed_dialect.go index b0bd879..46641bc 100644 --- a/typed_dialect.go +++ b/typed_dialect.go @@ -61,13 +61,27 @@ import ( // on, and a server logs it. Name the type or the rule, never the content. // - Name resolution (an assumption of the compiler, not checked): a statement // over one source writes bare column names, and the compiler relies on the -// PostgreSQL reading of them. A bare name that stands alone in ORDER BY is a -// select-list output first, so the compiler refuses an ORDER BY item whose -// written name another output shares (typedCheckOrderByName). In GROUP BY a +// PostgreSQL reading of them, and on the server taking two quoted names for +// one identifier exactly when the texts quoteIdent returned are equal. What +// quoteIdent does to a name is the dialect's own business, and it may fold +// case: PostgreSQL's FoldLower mode writes Total and total as "total". +// A bare name that stands alone in ORDER BY is a select-list output first, +// so the compiler refuses an ORDER BY item whose written name is the written +// name of another output (typedCheckOrderByName). That check compares the +// quoted names quoteIdent returned, never the query's spelling, so it holds +// for a folding dialect too. Everything else that looks a name up stays on the +// query's spelling, because it decides what DALgo means and not what the +// server reads: the lookup of a select alias (typedAliasRewriter), source +// qualifiers, and the comparison of ORDER BY with a source's scan +// (validateTypedScanRestated). Under a folding dialect those treat Total and +// TOTAL as different names, which makes them refuse or leave a name as the +// column, never pick another expression, and the ORDER BY check above catches +// the written names that then meet. Wildcard exclusions match the catalog's +// names exactly, as dal.WildcardProjection.Excludes defines. In GROUP BY a // bare name is an input column before it is an output, and HAVING cannot see -// outputs at all, so both write the input column as it is. A dialect for an -// engine that reads a select alias before an input column in GROUP BY or -// HAVING needs that check extended before it is added. +// outputs at all, so both write the input column as it is, under any case +// rule. A dialect for an engine that reads a select alias before an input +// column in GROUP BY or HAVING needs that check extended before it is added. // // The interface stays unexported until three dialects exist. type typedDialect interface { diff --git a/typed_dialect_fake_test.go b/typed_dialect_fake_test.go index c1dbbf1..64f8413 100644 --- a/typed_dialect_fake_test.go +++ b/typed_dialect_fake_test.go @@ -39,6 +39,23 @@ func newFakeTypedDialect() *fakeTypedDialect { } } +// newFoldingFakeTypedDialect is the fake dialect with a quoteIdent that folds +// case inside the quotes, as dalgo2postgres's quoteIdent does +// (sql_gen.go: `"` + strings.ToLower(name) + `"`) and as the PostgreSQL +// dialect's FoldLower mode will. Two spellings that differ only in case are then +// one identifier to the server, which a check that compares the query's +// spelling cannot see. +func newFoldingFakeTypedDialect() *fakeTypedDialect { + d := newFakeTypedDialect() + d.quoteOverride = func(name string) (string, error) { + if err := checkTypedIdentifier(name, d.identLimit); err != nil { + return "", err + } + return quoteTypedIdentifier(strings.ToLower(name), '"'), nil + } + return d +} + func fullFakeTypedCapabilities() dal.QueryCapabilities { return dal.QueryCapabilities{ GroupBy: true, diff --git a/typed_sql.go b/typed_sql.go index 87dd07b..3e1a004 100644 --- a/typed_sql.go +++ b/typed_sql.go @@ -215,7 +215,10 @@ func validateTypedJoinSources(from dal.FromSource, path string) error { // it. This check cannot see that, because it runs before the select list is // rendered; typedCheckOrderByName runs on the same item when the statement is // compiled and refuses such a statement, so a statement that passes both sorts by -// the scan's own column. +// the scan's own column. The comparison here is on the query's spelling, so under +// a dialect that folds case the scan order Total and an ORDER BY TOTAL are +// different and the statement is refused: it fails closed, and never accepts a +// pair that is not the same expression. // // Database() is not checked. A lone source in a named database is right only // because the federated executor already routed the leaf to that database's @@ -297,10 +300,18 @@ type typedSelectItem struct { expression dal.Expression sql string args []any - // name is the output column name the server gives the item: the alias, else - // the column's own name, else the text the compiler wrote after AS. It is empty - // for the bare "*", whose outputs are the input columns themselves. - name string + // output is the quoted name the server gives the item's output column, as the + // dialect wrote it: the alias, else the column's own name, else the text the + // compiler wrote after AS. It is empty for the bare "*", whose outputs are the + // input columns themselves. It is the quoted text, not the query's spelling, + // because the server compares what is written: a dialect that folds case in + // quoteIdent writes Total and TOTAL as one identifier. + output string + // column is, when expression is a field, that field's quoted name on its own + // (no source qualifier, no alias), as the dialect wrote it. Empty otherwise. + // An item whose output equals the bare quoted name of a column and whose + // column equals it too is that column itself. + column string } // query assembles the statement. FROM is rendered first because it fixes which @@ -413,7 +424,7 @@ func (c *typedCompiler) expandWildcard(wildcard *wildcardProjectionPlan) ([]type if err != nil { return nil, err } - items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted, name: column.Name}) + items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted, output: quoted, column: quoted}) } return items, nil } @@ -423,15 +434,25 @@ func (c *typedCompiler) expandWildcard(wildcard *wildcardProjectionPlan) ([]type // expression's own text. That last name must not contain a value, so an // unaliased expression that carries a constant is refused. func (c *typedCompiler) selectItem(column dal.Column) (typedSelectItem, error) { + var item typedSelectItem + field, isField := column.Expression.(dal.FieldRef) + if isField { + // The field's own written name, apart from any source qualifier or alias. + quoted, err := c.quote(field.Name()) + if err != nil { + return typedSelectItem{}, err + } + item.column = quoted + } sql, args, err := c.expr(column.Expression) if err != nil { return typedSelectItem{}, err } - item := typedSelectItem{expression: column.Expression, sql: sql, args: args} + item.expression, item.sql, item.args = column.Expression, sql, args name := column.Alias if name == "" { - if field, isField := column.Expression.(dal.FieldRef); isField { - item.name = field.Name() + if isField { + item.output = item.column return item, nil } if len(args) != 0 { @@ -444,7 +465,7 @@ func (c *typedCompiler) selectItem(column dal.Column) (typedSelectItem, error) { return typedSelectItem{}, err } item.sql += " AS " + quoted - item.name = name + item.output = quoted return item, nil } @@ -509,7 +530,8 @@ func (c *typedCompiler) having(condition dal.Condition, aliases map[string]dal.E // // An item that rewrites to a bare column name is checked by // typedCheckOrderByName: the server reads a bare ORDER BY name as a select-list -// output first, so the written name must not be one. +// output first, so the written name must not be one. The check compares the +// quoted names the dialect wrote, so a dialect that folds case is covered. func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelectItem, aliases map[string]dal.Expression, grouped bool) (string, []any, error) { if len(orders) == 0 { return "", nil, nil @@ -525,7 +547,7 @@ func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelec if err != nil { return "", nil, fmt.Errorf("orderBy %d: %w", i, err) } - if err := typedCheckOrderByName(expression, items); err != nil { + if err := typedCheckOrderByName(expression, sql, items); err != nil { return "", nil, fmt.Errorf("orderBy %d: %w", i, err) } if grouped && rewriter.groupedConstant != "" && len(values) != 0 { @@ -553,25 +575,31 @@ func (c *typedCompiler) orderBy(orders []dal.OrderExpression, items []typedSelec // where DALgo sorts by the group key. Every output of that name must be the // column itself, so any other one, not just the first, refuses the item. // -// The name an output has is typedSelectItem.name, so an unaliased expression, -// which the compiler names by its text, counts too. A qualified field cannot be -// captured, and a join statement writes no bare name (c.expr refuses it first), -// so a source on the item or on the output needs no comparison here. +// The comparison is on what was written, never on the query's spelling: written +// is the quoted name byExpression put in the text, item.output is the quoted name +// the dialect gave the output, and the item is the column itself when item.column, +// the quoted name of its own field, equals it. A dialect whose quoteIdent folds +// case writes Total and total as one identifier, and the server compares the +// written one, so `SELECT Total AS a, Name AS total ORDER BY a` is the capture +// above even though the query spells the column and the alias differently. For a +// dialect that quotes names as written, equal text and equal spelling coincide. +// +// An unaliased expression is named by its text (typedSelectItem.output holds the +// quoted text the compiler wrote after AS), so it counts too. A qualified field +// cannot be captured, and a join statement writes no bare name (c.expr refuses it +// first), so a source on the item or on the output needs no comparison here. // // The refusal sends the query to DALgo's generic engine. Writing the column // qualified by the base source would keep it native; refusing is the narrower // change, and the shape is rare. -func typedCheckOrderByName(expression dal.Expression, items []typedSelectItem) error { +func typedCheckOrderByName(expression dal.Expression, written string, items []typedSelectItem) error { field, ok := expression.(dal.FieldRef) if !ok || field.Source() != "" { return nil } for _, item := range items { - if item.name != field.Name() { - continue - } - if output, isField := item.expression.(dal.FieldRef); !isField || output.Name() != field.Name() { - return typedUnsupported("ORDER BY resolves to column %q, which the server reads as the select-list output of that name, a different expression", field.Name()) + if item.output == written && item.column != written { + return typedUnsupported("ORDER BY resolves to column %q (written %s), which the server reads as the select-list output of that name, a different expression", field.Name(), written) } } return nil @@ -1071,11 +1099,13 @@ const ( // text, as in (a + 1) / (a + 2), it cannot tell which operand is which. A dialect // that places the right operand before the left, in a form whose value depends on // the position such as (1.0 / right * left), would pass: the fragment is -// byte-identical to the correct (1.0 / left * right), the arguments stay in order, -// the first marker takes the left operand's value, and the statement computes the -// inverse quotient. (A plain (right / left) writes the same bytes as (left / -// right) for equal texts and does no harm there, but the probe refuses it too, -// because divide must be a function of its operand texts alone.) For this case the +// byte-identical to the same form with the operands in the order they were passed, +// (1.0 / left * right), so the text check cannot object, the arguments stay in +// order, the first marker takes the left operand's value, and the statement +// computes the inverse quotient. (A plain (right / left) writes the same bytes +// as (left / right) for equal texts and does no harm there, but the probe +// refuses it too, because divide must be a function of its operand texts +// alone.) For this case the // compiler asks the dialect to divide two probe operands that differ and checks // the order on them. An operand without a marker carries no argument, so a // division with one is not probed and the dialect keeps its freedom to repeat or diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go index d8a2074..c7cbfdb 100644 --- a/typed_sql_errors_test.go +++ b/typed_sql_errors_test.go @@ -28,6 +28,7 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) selectOf := func(e dal.Expression) dal.StructuredQuery { return album().SelectColumns(typedTestColumn(e, "x")) } + folding := newFoldingFakeTypedDialect() stableFirstLast := newFakeTypedDialect() stableFirstLast.caps.StableRowOrder = true stableFirstLast.caps.Aggregate.First = true @@ -163,6 +164,66 @@ func TestCompileTypedSQLRefusesUnsupportedNodesWithErrNotSupported(t *testing.T) SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestField("b")), "")), nil, `ORDER BY resolves to column "(a + b)"`, }, + // A dialect that folds case in quoteIdent writes two spellings that differ + // only in case as one identifier, and the server compares what is written. + // The guard therefore compares the quoted names the compiler wrote, not the + // query's spelling: statements that a spelling check lets through are refused + // here, and the ones that must still compile are in + // TestCompileTypedSQLBareNamesUnderAFoldingDialect. + { + "folding dialect: ORDER BY alias resolving to a column whose folded name another select alias takes", + album().OrderBy(dal.Descending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "total")), + folding, `ORDER BY resolves to column "Total" (written "total")`, + }, + { + // Total DESC is the scan and the alias resolves to it, so the statement + // passes validateTypedScanRestated, which compares spellings; the server + // would sort the ten rows by the output total, which is Name. + "folding dialect: scan whose ORDER BY alias resolves to the scan column under a folded name another alias takes", + dal.From(scanned).NewQuery().OrderBy(dal.Descending(typedTestField("a"))).Limit(10). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "total")), + folding, `ORDER BY resolves to column "Total" (written "total")`, + }, + { + // DALgo reads ORDER BY A as the input column A, since no alias is spelled + // that way; written, it is the output a, which is Total. + "folding dialect: ORDER BY a column that differs from a select alias only in case", + album().OrderBy(dal.Ascending(typedTestField("A"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a")), + folding, `ORDER BY resolves to column "A" (written "a")`, + }, + { + "folding dialect: ORDER BY a column that differs from a select alias only in case, the alias in capitals", + album().OrderBy(dal.Ascending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "A")), + folding, `ORDER BY resolves to column "a" (written "a")`, + }, + { + // The scan comparison stays on the query's spelling, so it refuses a pair + // the server would read as one column; it never accepts a different one. + "folding dialect: scan whose ORDER BY spells the scan column in another case", + dal.From(scanned).NewQuery().OrderBy(dal.Descending(typedTestField("TOTAL"))).Limit(10).SelectColumns(), + folding, "ORDER BY differs from the scan order", + }, + { + "folding dialect: ORDER BY a column whose name another output differs from only in case", + album().OrderBy(dal.Ascending(typedTestField("Total"))). + SelectColumns(typedTestColumn(typedTestField("Name"), "total")), + folding, `ORDER BY resolves to column "Total" (written "total")`, + }, + { + "folding dialect: grouped ORDER BY alias of a group key that another select alias takes under a folded name", + album().GroupBy(typedTestField("a")).OrderBy(dal.Ascending(typedTestField("x"))). + SelectColumns(typedTestColumn(typedTestField("a"), "x"), dal.CountAs(typedTestField("b"), "A")), + folding, `ORDER BY resolves to column "a" (written "a")`, + }, + { + "folding dialect: ORDER BY a column named like an unaliased select expression, written in another case", + album().OrderBy(dal.Ascending(typedTestField("(A + B)"))). + SelectColumns(typedTestColumn(dal.Binary(typedTestField("a"), dal.Add, typedTestField("b")), "")), + folding, `ORDER BY resolves to column "(A + B)" (written "(a + b)")`, + }, {"query carrying a DTQL money option", typedMoneyQuery{StructuredQuery: album().SelectColumns(), money: &dal.MoneyConfig{MinorUnitScale: 2, DivisionScale: 4, Rounding: "halfEven"}}, nil, "money"}, {"scan-bounded base source in a join", joinedTo(scanned, plainCustomer), nil, "from: join_plan: a scan-bounded source"}, {"scan limit alone on the base source of a join", joinedTo(scanLimitOnly, plainCustomer), nil, "scan-bounded"}, diff --git a/typed_sql_test.go b/typed_sql_test.go index 3a2c711..0be8b9e 100644 --- a/typed_sql_test.go +++ b/typed_sql_test.go @@ -550,6 +550,102 @@ func TestCompileTypedSQLBareNamesAreReadAsInputColumns(t *testing.T) { wantSQL: `SELECT "a" AS "x", COUNT("b") AS "a" FROM "Invoice" GROUP BY "a" HAVING "a" > $1::bigint`, wantArgs: []any{1}, }, + { + // A dialect that quotes names as written keeps Total and total apart, so + // ORDER BY Total reads the input column and the output total is not a + // capture. The same statement is refused under a folding dialect + // (typed_sql_errors_test.go), which is why the guard compares what is written. + name: "ORDER BY a column that differs from a select alias only in case, with exact quoting", + query: invoice().OrderBy(dal.Ascending(typedTestField("Total"))). + SelectColumns(typedTestColumn(typedTestField("Name"), "total")), + wantSQL: `SELECT "Name" AS "total" FROM "Invoice" ORDER BY "Total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY an alias whose column name is spelled in another case than the alias that follows it, with exact quoting", + query: invoice().OrderBy(dal.Descending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "total")), + wantSQL: `SELECT "Total" AS "a", "Name" AS "total" FROM "Invoice" ORDER BY "Total" DESC NULLS LAST`, + }, + }) +} + +// TestCompileTypedSQLBareNamesUnderAFoldingDialect repeats +// TestCompileTypedSQLBareNamesAreReadAsInputColumns for a dialect whose quoteIdent +// folds case (newFoldingFakeTypedDialect), where two spellings that differ only in +// case are one identifier to the server. The refusals are in +// typed_sql_errors_test.go; these are the statements that must still compile. +// +// ORDER BY compares the quoted names the compiler wrote: a bare name is a capture +// only when an output carries the same written name and is not the column itself. +// GROUP BY and HAVING need no comparison under any case rule, because GROUP BY +// reads an input column before an output and HAVING cannot see outputs; the two +// goldens at the end show the written text is the input column's name. +func TestCompileTypedSQLBareNamesUnderAFoldingDialect(t *testing.T) { + folding := newFoldingFakeTypedDialect() + invoice := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + runTypedGoldens(t, []typedGolden{ + { + // The second output is the column Total written as TOTAL: same identifier, + // so the server's output total is the column itself, not another expression. + name: "ORDER BY alias of a column that is also selected under another spelling of its own name", + dialect: folding, + query: invoice().OrderBy(dal.Ascending(typedTestField("a"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("TOTAL"), "")), + wantSQL: `SELECT "total" AS "a", "total" FROM "invoice" ORDER BY "total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY a column selected under an alias that folds to its own name", + dialect: folding, + query: invoice().OrderBy(dal.Descending(typedTestField("Total"))). + SelectColumns(typedTestColumn(typedTestField("Total"), "TOTAL")), + wantSQL: `SELECT "total" AS "total" FROM "invoice" ORDER BY "total" DESC NULLS LAST`, + }, + { + name: "ORDER BY a column that no output shares a written name with", + dialect: folding, + query: invoice().OrderBy(dal.Ascending(typedTestField("Total"))). + SelectColumns(typedTestColumn(typedTestField("Name"), "n"), typedTestColumn(typedTestField("Total"), "a")), + wantSQL: `SELECT "name" AS "n", "total" AS "a" FROM "invoice" ORDER BY "total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY a column and a select-all whose column folds to the same name", + dialect: folding, + facts: typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{{Name: "invoice"}: {Columns: []typedColumnFact{{Name: "total"}, {Name: "name"}, {Name: "secret"}}}}}, + query: invoice().OrderBy(dal.Ascending(typedTestField("Total"))). + SelectColumns(dal.AllColumnsExcept("secret")), + wantSQL: `SELECT "total", "name" FROM "invoice" ORDER BY "total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY a qualified name that another select alias takes under a folded spelling", + dialect: folding, + query: typedTestFrom("Invoice", "i").NewQuery().OrderBy(dal.Ascending(typedTestQualified("i", "Total"))). + SelectColumns(typedTestColumn(typedTestField("Name"), "total")), + wantSQL: `SELECT "name" AS "total" FROM "invoice" AS "i" ORDER BY "i"."total" ASC NULLS FIRST`, + }, + { + name: "ORDER BY alias nested in an expression, when another select alias takes the column's folded name", + dialect: folding, + query: invoice().OrderBy(dal.Ascending(dal.Binary(typedTestField("a"), dal.Add, typedTestConst(1)))). + SelectColumns(typedTestColumn(typedTestField("Total"), "a"), typedTestColumn(typedTestField("Name"), "total")), + wantSQL: `SELECT "total" AS "a", "name" AS "total" FROM "invoice" ORDER BY ("total" + $1::bigint) ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "GROUP BY a column that another select alias takes under a folded spelling", + dialect: folding, + query: invoice().GroupBy(typedTestField("A")). + SelectColumns(typedTestColumn(typedTestField("A"), "x"), dal.CountAs(typedTestField("b"), "a")), + wantSQL: `SELECT "a" AS "x", COUNT("b") AS "a" FROM "invoice" GROUP BY "a"`, + }, + { + name: "HAVING alias of a group key, when another select alias takes the key's folded name", + dialect: folding, + query: invoice().GroupBy(typedTestField("A")). + Having(dal.NewComparison(typedTestField("x"), dal.GreaterThen, typedTestConst(1))). + SelectColumns(typedTestColumn(typedTestField("A"), "x"), dal.CountAs(typedTestField("b"), "a")), + wantSQL: `SELECT "a" AS "x", COUNT("b") AS "a" FROM "invoice" GROUP BY "a" HAVING "a" > $1::bigint`, + wantArgs: []any{1}, + }, }) } From 30a8f1ac427282a241745916074d3dcb2d4c051f Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sun, 4 Oct 2026 23:20:40 +0100 Subject: [PATCH 6/6] fix(typed-sql): refuse a field name the server would read as the whole row or a function of it A name that matches no column is not an error to PostgreSQL: a bare name equal to a source's identity is the whole row, and a qualified x.f is f(x). With the catalog facts for a source, a field must now be one of its columns (plain error otherwise); without them, a bare name written as the base source's identity is refused with ErrNotSupported, compared on the quoted names written. The dialect contract states both readings and that SQL-04 must pass facts for every source. A facts lookup now matches a column only when the catalog's own name is the folded query name, and a select-all is refused over a catalog column that is not in its folded form. Co-Authored-By: Claude Sonnet 5.5 --- typed_dialect.go | 65 +++++++++++++++---- typed_dialect_test.go | 27 ++++++++ typed_sql.go | 58 ++++++++++++++++- typed_sql_errors_test.go | 137 ++++++++++++++++++++++++++++++++++++++- typed_sql_test.go | 125 ++++++++++++++++++++++++++++++++++- 5 files changed, 393 insertions(+), 19 deletions(-) diff --git a/typed_dialect.go b/typed_dialect.go index 46641bc..37d48e0 100644 --- a/typed_dialect.go +++ b/typed_dialect.go @@ -69,19 +69,40 @@ import ( // so the compiler refuses an ORDER BY item whose written name is the written // name of another output (typedCheckOrderByName). That check compares the // quoted names quoteIdent returned, never the query's spelling, so it holds -// for a folding dialect too. Everything else that looks a name up stays on the -// query's spelling, because it decides what DALgo means and not what the -// server reads: the lookup of a select alias (typedAliasRewriter), source -// qualifiers, and the comparison of ORDER BY with a source's scan -// (validateTypedScanRestated). Under a folding dialect those treat Total and -// TOTAL as different names, which makes them refuse or leave a name as the -// column, never pick another expression, and the ORDER BY check above catches -// the written names that then meet. Wildcard exclusions match the catalog's -// names exactly, as dal.WildcardProjection.Excludes defines. In GROUP BY a +// for a folding dialect too. Three other kinds of lookup do not compare +// written names. The first looks a name up by the query's spelling, because it +// decides what DALgo means and not what the server reads: the lookup of a +// select alias (typedAliasRewriter), source qualifiers, and the comparison of +// ORDER BY with a source's scan (validateTypedScanRestated). Under a folding +// dialect those treat Total and TOTAL as different names, which makes them +// refuse or leave a name as the column, never pick another expression, and the +// ORDER BY check above catches the written names that then meet. The second +// matches a wildcard exclusion against the catalog's names exactly, as +// dal.WildcardProjection.Excludes defines. The third is the catalog facts +// lookup, which folds the query's name with typedCatalogFacts.Fold and matches +// a column only when the catalog's own name equals the result: Fold must be +// the case rule quoteIdent applies, a catalog column whose name is not its own +// folded form is never matched, and a select-all that would have to write one +// is refused, because the statement could not name that column. In GROUP BY a // bare name is an input column before it is an output, and HAVING cannot see // outputs at all, so both write the input column as it is, under any case // rule. A dialect for an engine that reads a select alias before an input // column in GROUP BY or HAVING needs that check extended before it is added. +// - A name that matches no column is not an error to the server, it is read as +// something else: a bare name equal to a source's identity (its alias, else its +// table name) as the whole row, one composite value that holds every column, +// and a qualified name x.f that is no column of x as the function f applied to +// the row of x (x.to_json is to_json(x); row_to_json, to_jsonb, concat, count +// and quote_literal read alike). Either returns the row inside one value, past +// any policy that checks field names. The compiler closes both with the +// catalog facts (typedCompiler.checkNamesAColumn): a field of a source whose +// facts are known must be one of its columns, else the statement fails with a +// plain error naming the column; and when the facts do not know the source, an +// unqualified field written as the base source's identity is refused with +// dal.ErrNotSupported. A qualified name without facts cannot be checked, so +// the caller (SQL-04) must pass catalog facts for every source it compiles, +// and each source's Columns must list every column a query may name (a system +// column such as ctid is not one unless the dialect lists it). // // The interface stays unexported until three dialects exist. type typedDialect interface { @@ -181,11 +202,19 @@ type typedSourceFacts struct { // typedCatalogFacts is the compiler's whole knowledge of the database. The // zero value knows nothing, and the compiler then emits correct but less // optimised SQL (no wildcard expansion, no NOT NULL shortcuts, no join key -// type check). +// type check) and cannot check that a field is a column: it refuses a bare name +// written as the base source's identity, and leaves a qualified name to the +// server, which may read it as a function of the row. Pass facts for every source +// of a query (see the name-resolution rules in typedDialect). type typedCatalogFacts struct { // Fold maps a name to the key it is stored under, for engines or modes - // that fold identifier case. Nil means names are matched exactly. - Fold func(string) string + // that fold identifier case. Nil means names are matched exactly. It must be + // the case rule the dialect's quoteIdent applies, and Sources holds the folded + // names: a column is matched when its name is the folded name of the query's. + Fold func(string) string + // Sources lists the columns of every source the query reads, all of them (see + // the name-resolution rules in typedDialect): a field the facts of a known + // source do not list is refused. Sources map[typedSourceName]typedSourceFacts } @@ -196,11 +225,21 @@ func (f typedCatalogFacts) fold(name string) string { return f.Fold(name) } +// addressable reports whether a statement can write a catalog name: the name is +// its own folded form (every name is, when nothing folds). A column that is not +// can never be matched by column, and a select-all cannot list it. +func (f typedCatalogFacts) addressable(name string) bool { return f.fold(name) == name } + func (f typedCatalogFacts) source(name typedSourceName) (typedSourceFacts, bool) { source, ok := f.Sources[typedSourceName{Schema: f.fold(name.Schema), Name: f.fold(name.Name)}] return source, ok } +// column finds the column a statement means when it writes the name column. The +// statement writes the folded name, so a catalog column matches only when its own +// name is that folded name: comparing both sides through Fold would let a column +// the dialect cannot write (Total, where the statement says "total") stand in for +// another, or take the first of several that fold together. func (f typedCatalogFacts) column(name typedSourceName, column string) (typedColumnFact, bool) { source, ok := f.source(name) if !ok { @@ -208,7 +247,7 @@ func (f typedCatalogFacts) column(name typedSourceName, column string) (typedCol } key := f.fold(column) for _, candidate := range source.Columns { - if f.fold(candidate.Name) == key { + if candidate.Name == key { return candidate, true } } diff --git a/typed_dialect_test.go b/typed_dialect_test.go index f6293bb..d7df596 100644 --- a/typed_dialect_test.go +++ b/typed_dialect_test.go @@ -160,6 +160,33 @@ func TestTypedCatalogFactsLookups(t *testing.T) { t.Fatalf("column() = %v, %v", column, ok) } }) + // A folding dialect writes the folded name, so a column matches only when the + // catalog's own name is that name; the first column that folds to the same key + // is not enough. + t.Run("folded lookup matches the catalog's own name only", func(t *testing.T) { + album := typedSourceName{Name: "album"} + colliding := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + album: {Columns: []typedColumnFact{{Name: "Title", NotNull: true}, {Name: "title"}}}, + }} + if column, ok := colliding.column(album, "TITLE"); !ok || column.NotNull || column.Name != "title" { + t.Fatalf("column(TITLE) = %v, %v; want the column named title, not the first one that folds to it", column, ok) + } + mixedOnly := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + album: {Columns: []typedColumnFact{{Name: "Title"}}}, + }} + for _, spelling := range []string{"Title", "title", "TITLE"} { + if column, ok := mixedOnly.column(album, spelling); ok { + t.Fatalf("column(%s) = %v; a catalog name the dialect cannot write must never match", spelling, column) + } + } + if !colliding.addressable("title") || colliding.addressable("Title") { + t.Fatal("addressable() must hold exactly for a name that is its own folded form") + } + var exact typedCatalogFacts + if !exact.addressable("Title") { + t.Fatal("without a fold every name is addressable") + } + }) t.Run("zero value has no facts", func(t *testing.T) { var none typedCatalogFacts if _, ok := none.source(typedSourceName{Name: "album"}); ok { diff --git a/typed_sql.go b/typed_sql.go index 3e1a004..10015bc 100644 --- a/typed_sql.go +++ b/typed_sql.go @@ -63,8 +63,11 @@ func typedUnsupported(format string, args ...any) error { // dal.ErrNotSupported rather than an approximation: an unrecognised node, a // query option the statement cannot honour (the DTQL money option, a source's // scan or database: see validateTypedJoinSources and validateTypedScanRestated), -// a reference the server could not match (typedCheckOrderByName). DALgo's -// generic engine remains the place for those queries. +// a reference the server could not match (typedCheckOrderByName), and a bare name +// the server would read as the whole row (typedCompiler.checkNamesAColumn). +// DALgo's generic engine remains the place for those queries. A field that the +// catalog facts of a known source do not list is a plain error, not a refusal: +// the table has no such column. func compileTypedSQL(q dal.StructuredQuery, dialect typedDialect, facts typedCatalogFacts) (string, []any, error) { if dialect == nil { return "", nil, errors.New("typed SQL compiler requires a dialect") @@ -417,6 +420,12 @@ func (c *typedCompiler) expandWildcard(wildcard *wildcardProjectionPlan) ([]type } items := make([]typedSelectItem, 0, len(source.Columns)) for _, column := range source.Columns { + // The statement writes the dialect's folded name, so a column whose own name + // is not that name cannot be listed, and writing the folded one would return + // another column, or the same one twice. + if !c.facts.addressable(column.Name) { + return nil, typedUnsupported("a wildcard projection cannot list catalog column %q: the dialect writes its name in another form", column.Name) + } if wildcard.projection.Excludes(column.Name) { continue } @@ -961,15 +970,60 @@ func (c *typedCompiler) field(field dal.FieldRef, sources map[string]typedSource if requireQualified { return "", typedUnsupported("field %q must name a source in a JOIN query", field.Name()) } + // Without a qualifier the field belongs to the single FROM source. + if err := c.checkNamesAColumn(field, name, sources[c.base]); err != nil { + return "", err + } return name, nil } source, ok := sources[field.Source()] if !ok { return "", fmt.Errorf("field %q references unknown source %q", field.Name(), field.Source()) } + if err := c.checkNamesAColumn(field, name, source); err != nil { + return "", err + } return source.quoted + "." + name, nil } +// checkNamesAColumn refuses a field the server could read as something other than +// a column of its source. PostgreSQL reads a name that matches no column as the +// whole row of a source when it is the source's identity (a bare name: the alias, +// else the table name), and as a function of the row when it is qualified (x.f is +// f(x), documented as the table.func notation: to_json, row_to_json, to_jsonb, +// concat, count, quote_literal read alike). Either hands back every column of the +// row inside one value, past a field policy that checks names only, and the +// caller chooses both the alias and the field. +// +// Two cases, decided by what the catalog facts say about the source: +// +// - The facts know the source: the field must be one of its columns (the lookup +// matches the name the dialect writes, see typedCatalogFacts.column). Anything +// else is a plain error naming the column, as an unknown source is. It holds +// for a bare and for a qualified field, in every clause and in a join's ON. +// - The facts do not know the source: a bare field whose written name is the +// written identity of its source is refused, because without the column list +// the compiler cannot tell the column of that name from the whole row. The +// comparison is on the quoted texts, as the ORDER BY guard's is, so a dialect +// that folds case in quoteIdent is covered. A qualified field cannot be +// checked without facts, which is why the caller must pass facts for every +// source it compiles (the typedDialect contract). +// +// An aliased source hides its table's own name, so only the alias is an identity +// here: a bare name equal to the table name is then no whole-row reference. +func (c *typedCompiler) checkNamesAColumn(field dal.FieldRef, written string, source typedSource) error { + if _, known := c.facts.source(source.table); known { + if _, ok := c.facts.column(source.table, field.Name()); !ok { + return fmt.Errorf("field %q is not a column of source %s in the catalog facts; the server would read the name as the row of the source or as a function of it", field.Name(), source.quoted) + } + return nil + } + if field.Source() == "" && written == source.quoted { + return typedUnsupported("field %q is written as the identity of its source %s, which the server reads as the whole row unless a column has that name, and no catalog facts say whether one does", field.Name(), source.quoted) + } + return nil +} + // quote is the only way an identifier reaches the SQL text. The dialect // validates and quotes; the compiler then checks the result is one well-formed // quoted identifier, so a faulty dialect cannot let a name escape its quotes. diff --git a/typed_sql_errors_test.go b/typed_sql_errors_test.go index c7cbfdb..979e9a6 100644 --- a/typed_sql_errors_test.go +++ b/typed_sql_errors_test.go @@ -706,8 +706,6 @@ func TestCompileTypedSQLCheckedJoinKeyTypes(t *testing.T) { {"same category different widths", "ArtistId", "ArtistId"}, {"same non-scalar type", "Guid", "Guid"}, {"same unknown type name", "Mystery", "Mystery"}, - {"column missing from the facts is left to the server", "Missing", "ArtistId"}, - {"other side missing from the facts", "ArtistId", "Missing"}, } for _, tc := range accepted { t.Run("accepts "+tc.name, func(t *testing.T) { @@ -727,6 +725,18 @@ func TestCompileTypedSQLCheckedJoinKeyTypes(t *testing.T) { expectTypedUnsupported(t, join(tc.album, tc.artist), nil, facts, "join_plan") }) } + // A key the facts of a known source do not list is no longer left to the server: + // the server would read r.Missing as the function Missing applied to the row of r + // (TestCompileTypedSQLRefusesANameThatIsNotAColumnOfAKnownSource). It is a plain + // error, not a join_plan refusal. + for _, tc := range []struct{ name, album, artist, fragment string }{ + {"a key missing from the facts of the left source", "Missing", "ArtistId", `on[0].left: field "Missing"`}, + {"a key missing from the facts of the right source", "ArtistId", "Missing", `on[0].right: field "Missing"`}, + } { + t.Run("rejects "+tc.name, func(t *testing.T) { + expectTypedInvalid(t, join(tc.album, tc.artist), nil, facts, tc.fragment) + }) + } t.Run("without facts nothing is checked", func(t *testing.T) { if _, _, err := compileTypedSQL(join("ArtistId", "Name"), newFakeTypedDialect(), typedCatalogFacts{}); err != nil { t.Fatalf("compileTypedSQL() error = %v", err) @@ -829,3 +839,126 @@ func TestTypedQuerySources(t *testing.T) { t.Fatalf("typedQuerySources(nil) = %v, want none", got) } } + +// TestCompileTypedSQLRefusesAWholeRowNameWithoutFacts: PostgreSQL reads a bare name +// that is no column of the statement's source as the source's identity, the whole +// row as one composite value, when the name is the identity (the alias, else the +// table name). The caller chooses both the alias and the field, so SELECT x FROM +// Invoice AS x would return every column of the row inside one value, past a +// policy that checks field names only. Without catalog facts the compiler cannot +// tell whether a column has that name, so it refuses the written identity +// (typedCompiler.checkNamesAColumn). With facts it asks the facts instead +// (TestCompileTypedSQLRefusesANameThatIsNotAColumnOfAKnownSource). +// +// The comparison is on the quoted names written, as the ORDER BY guard's is, so a +// dialect that folds case in quoteIdent is covered: X and x are one identifier. +func TestCompileTypedSQLRefusesAWholeRowNameWithoutFacts(t *testing.T) { + folding := newFoldingFakeTypedDialect() + aliased := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "x").NewQuery() } + plain := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + inSchema := func() dal.IQueryBuilder { + return dal.From(dal.NewQualifiedRootCollectionRef("public", "Invoice", "")).NewQuery() + } + total := typedTestColumn(typedTestField("Total"), "") + countOf := func(name string) dal.Expression { return dal.NewAggregate(dal.COUNT, false, typedTestField(name)) } + cases := []struct { + name string + query dal.StructuredQuery + dialect typedDialect + fragment string + }{ + {"select list, the alias", aliased().SelectColumns(typedTestColumn(typedTestField("x"), "")), nil, `field "x" is written as the identity of its source`}, + {"select list, the alias, under an output alias", aliased().SelectColumns(typedTestColumn(typedTestField("x"), "row")), nil, `field "x"`}, + {"select list, the table name", plain().SelectColumns(typedTestColumn(typedTestField("Invoice"), "")), nil, `field "Invoice"`}, + {"select list, the table name of a schema-qualified source", inSchema().SelectColumns(typedTestColumn(typedTestField("Invoice"), "")), nil, `field "Invoice"`}, + {"WHERE compares whole rows", aliased().Where(typedTestEq(typedTestField("x"), 1)).SelectColumns(total), nil, `field "x"`}, + {"WHERE IS NULL on the table name", plain().Where(dal.NewIsNullCondition(typedTestField("Invoice"))).SelectColumns(total), nil, `field "Invoice"`}, + {"ORDER BY sorts whole rows", aliased().OrderBy(dal.Ascending(typedTestField("x"))).SelectColumns(total), nil, `field "x"`}, + {"ORDER BY the table name", plain().OrderBy(dal.Descending(typedTestField("Invoice"))).SelectColumns(total), nil, `field "Invoice"`}, + {"GROUP BY groups whole rows", aliased().GroupBy(typedTestField("x")).SelectColumns(dal.Count()), nil, `field "x"`}, + {"an aggregate counts whole rows", aliased().SelectColumns(dal.CountAs(typedTestField("x"), "n")), nil, `field "x"`}, + { + "HAVING over an aggregate of the whole row", + aliased().GroupBy(typedTestField("g")).Having(dal.NewComparison(countOf("x"), dal.GreaterThen, typedTestConst(1))).SelectColumns(typedTestColumn(typedTestField("g"), "")), + nil, `field "x"`, + }, + {"folding dialect: the alias written in capitals", aliased().SelectColumns(typedTestColumn(typedTestField("X"), "")), folding, `field "X" is written as the identity of its source "x"`}, + {"folding dialect: an alias in capitals, the field in lower case", typedTestFrom("Invoice", "X").NewQuery().SelectColumns(typedTestColumn(typedTestField("x"), "")), folding, `field "x"`}, + {"folding dialect: the table name in capitals", plain().SelectColumns(typedTestColumn(typedTestField("INVOICE"), "")), folding, `field "INVOICE"`}, + {"folding dialect: ORDER BY the alias in capitals", aliased().OrderBy(dal.Ascending(typedTestField("X"))).SelectColumns(total), folding, `field "X"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectTypedUnsupported(t, tc.query, tc.dialect, typedCatalogFacts{}, tc.fragment) + }) + } +} + +// TestCompileTypedSQLRefusesANameThatIsNotAColumnOfAKnownSource: when the catalog +// facts know a source, a field it names must be one of that source's columns. A +// name that is no column is not an error to PostgreSQL, it is read as something +// else: a bare name as the whole row (the alias or table name), and a qualified +// name x.f as the function f applied to the row of x (x.to_json is to_json(x), and +// row_to_json, to_jsonb, concat and count read alike). Either returns the row +// inside one value, so a policy that checks field names would pass it and leak +// every column. The error is a plain one, not ErrNotSupported: the query names a +// column the table does not have, as an unknown source does. +func TestCompileTypedSQLRefusesANameThatIsNotAColumnOfAKnownSource(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Name: "Invoice"}: {Columns: []typedColumnFact{{Name: "InvoiceId"}, {Name: "Total"}, {Name: "CustomerId"}}}, + {Name: "Customer"}: {Columns: []typedColumnFact{{Name: "CustomerId"}, {Name: "Name"}}}, + }} + foldingFacts := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + {Name: "invoice"}: {Columns: []typedColumnFact{{Name: "invoiceid"}, {Name: "total"}}}, + }} + mixedCaseFacts := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + {Name: "invoice"}: {Columns: []typedColumnFact{{Name: "Total"}, {Name: "name"}}}, + }} + folding := newFoldingFakeTypedDialect() + aliased := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "x").NewQuery() } + plain := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + total := typedTestColumn(typedTestField("Total"), "") + countOf := func(name string) dal.Expression { return dal.NewAggregate(dal.COUNT, false, typedTestField(name)) } + joined := func(customerField string) dal.StructuredQuery { + return typedTestFrom("Invoice", "i").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Customer", "c"), dal.JoinInner, typedTestJoinOn("i", "CustomerId", "c", customerField)), + ).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("c", "Name"), "")) + } + cases := []struct { + name string + query dal.StructuredQuery + facts typedCatalogFacts + dialect typedDialect + fragment string + }{ + {"the alias read as the whole row", aliased().SelectColumns(typedTestColumn(typedTestField("x"), "")), facts, nil, `field "x" is not a column of source "x"`}, + {"the table name read as the whole row", plain().SelectColumns(typedTestColumn(typedTestField("Invoice"), "")), facts, nil, `field "Invoice" is not a column of source "Invoice"`}, + {"a qualified name read as a function of the row", aliased().SelectColumns(typedTestColumn(typedTestQualified("x", "to_json"), "")), facts, nil, `field "to_json" is not a column of source "x"`}, + {"a qualified name, written under an output alias", aliased().SelectColumns(typedTestColumn(typedTestQualified("x", "row_to_json"), "j")), facts, nil, `field "row_to_json"`}, + {"a misspelt column", plain().SelectColumns(typedTestColumn(typedTestField("Totl"), "")), facts, nil, `field "Totl"`}, + {"WHERE, bare", aliased().Where(typedTestEq(typedTestField("x"), 1)).SelectColumns(total), facts, nil, `field "x"`}, + {"WHERE, qualified", aliased().Where(typedTestEq(typedTestQualified("x", "to_jsonb"), 1)).SelectColumns(total), facts, nil, `field "to_jsonb"`}, + {"ORDER BY, bare", aliased().OrderBy(dal.Ascending(typedTestField("x"))).SelectColumns(total), facts, nil, `field "x"`}, + {"ORDER BY, qualified", aliased().OrderBy(dal.Ascending(typedTestQualified("x", "concat"))).SelectColumns(total), facts, nil, `field "concat"`}, + {"GROUP BY, bare", aliased().GroupBy(typedTestField("x")).SelectColumns(dal.Count()), facts, nil, `field "x"`}, + {"an aggregate argument", aliased().SelectColumns(dal.CountAs(typedTestQualified("x", "quote_literal"), "n")), facts, nil, `field "quote_literal"`}, + { + "HAVING over an aggregate of the whole row", + aliased().GroupBy(typedTestField("InvoiceId")).Having(dal.NewComparison(countOf("x"), dal.GreaterThen, typedTestConst(1))).SelectColumns(typedTestColumn(typedTestField("InvoiceId"), "")), + facts, nil, `field "x"`, + }, + {"a JOIN key read as a function of the row", joined("to_json"), facts, nil, `from.joins[0].on[0].right: field "to_json" is not a column of source "c"`}, + {"a JOIN key on the left, missing from the facts", typedTestFrom("Invoice", "i").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Customer", "c"), dal.JoinInner, typedTestJoinOn("i", "Missing", "c", "CustomerId")), + ).NewQuery().SelectColumns(), facts, nil, `on[0].left: field "Missing"`}, + {"folding dialect: the alias in capitals", aliased().SelectColumns(typedTestColumn(typedTestField("X"), "")), foldingFacts, folding, `field "X" is not a column of source "x"`}, + // The dialect writes "total", which the catalog does not hold: the column there + // is Total, a different identifier, and the server would not find it. + {"folding dialect: a catalog column that is not in its folded form", plain().SelectColumns(typedTestColumn(typedTestField("Total"), "")), mixedCaseFacts, folding, `field "Total"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectTypedInvalid(t, tc.query, tc.dialect, tc.facts, tc.fragment) + }) + } +} diff --git a/typed_sql_test.go b/typed_sql_test.go index 0be8b9e..09f1edd 100644 --- a/typed_sql_test.go +++ b/typed_sql_test.go @@ -369,8 +369,10 @@ func TestCompileTypedSQLAggregates(t *testing.T) { func TestCompileTypedSQLOrderBy(t *testing.T) { facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ - {Name: "Album"}: {Columns: []typedColumnFact{{Name: "AlbumId", NotNull: true}, {Name: "Title"}}}, - {Name: "Artist"}: {Columns: []typedColumnFact{{Name: "ArtistId", NotNull: true}, {Name: "Name"}}}, + // ArtistId is on both sides because the join keys below read it: a field of a + // known source must be one of its columns. + {Name: "Album"}: {Columns: []typedColumnFact{{Name: "AlbumId", NotNull: true}, {Name: "Title"}, {Name: "ArtistId", DataType: "int4", Category: typedTypeNumber}}}, + {Name: "Artist"}: {Columns: []typedColumnFact{{Name: "ArtistId", NotNull: true, DataType: "int4", Category: typedTypeNumber}, {Name: "Name"}}}, }} album := func() dal.IQueryBuilder { return typedTestFrom("Album", "").NewQuery() } runTypedGoldens(t, []typedGolden{ @@ -892,3 +894,122 @@ func TestCompileTypedSQLArgumentLimitIsPerStatement(t *testing.T) { } func typedTestPtr[T any](value T) *T { return &value } + +// TestCompileTypedSQLNamesThatAreColumnsOfAKnownSource pins what must keep +// compiling beside the refusals in typed_sql_errors_test.go: a name the catalog +// facts list is a column, and PostgreSQL reads a bare name as a column before it +// reads it as the whole row, so a column that shares its name with its table or +// alias is read as the column. A qualified column the facts list compiles too. +func TestCompileTypedSQLNamesThatAreColumnsOfAKnownSource(t *testing.T) { + facts := typedCatalogFacts{Sources: map[typedSourceName]typedSourceFacts{ + {Name: "Invoice"}: {Columns: []typedColumnFact{{Name: "Invoice"}, {Name: "x"}, {Name: "Total"}, {Name: "CustomerId", DataType: "int4", Category: typedTypeNumber}}}, + {Name: "Customer"}: {Columns: []typedColumnFact{{Name: "CustomerId", DataType: "int8", Category: typedTypeNumber}, {Name: "Name"}}}, + }} + runTypedGoldens(t, []typedGolden{ + { + name: "a column named like its table", + facts: facts, + query: typedTestFrom("Invoice", "").NewQuery().SelectColumns(typedTestColumn(typedTestField("Invoice"), "")), + wantSQL: `SELECT "Invoice" FROM "Invoice"`, + }, + { + name: "a column named like its alias, in every clause", + facts: facts, + query: typedTestFrom("Invoice", "x").NewQuery(). + Where(typedTestEq(typedTestField("x"), 1)). + OrderBy(dal.Ascending(typedTestField("x"))). + SelectColumns(typedTestColumn(typedTestField("x"), "")), + wantSQL: `SELECT "x" FROM "Invoice" AS "x" WHERE "x" = $1::bigint ORDER BY "x" ASC NULLS FIRST`, + wantArgs: []any{1}, + }, + { + name: "a qualified column", + facts: facts, + query: typedTestFrom("Invoice", "i").NewQuery().SelectColumns(typedTestColumn(typedTestQualified("i", "Total"), "")), + wantSQL: `SELECT "i"."Total" FROM "Invoice" AS "i"`, + }, + { + name: "a qualified column named like its alias", + facts: facts, + query: typedTestFrom("Invoice", "x").NewQuery().SelectColumns(typedTestColumn(typedTestQualified("x", "x"), "")), + // x.x is the column x of x + wantSQL: `SELECT "x"."x" FROM "Invoice" AS "x"`, + }, + { + name: "join keys that the facts list", + facts: facts, + query: typedTestFrom("Invoice", "i").Join( + dal.NewJoinedSource(dal.NewRootCollectionRef("Customer", "c"), dal.JoinInner, typedTestJoinOn("i", "CustomerId", "c", "CustomerId")), + ).NewQuery().SelectColumns(typedTestColumn(typedTestQualified("c", "Name"), "")), + wantSQL: `SELECT "c"."Name" FROM "Invoice" AS "i" INNER JOIN "Customer" AS "c" ON ("i"."CustomerId" = "c"."CustomerId")`, + }, + { + // The facts know nothing of Genre, so the name is not checked; it is not + // Genre's identity either. + name: "a source the facts do not know, with a bare name that is not its identity", + facts: facts, + query: typedTestFrom("Genre", "").NewQuery().SelectColumns(typedTestColumn(typedTestField("Name"), "")), + wantSQL: `SELECT "Name" FROM "Genre"`, + }, + { + // Only a bare name can be the whole row. A qualified name is not compared + // with the identity, and without facts it is not checked at all, which is why + // the typedDialect contract makes SQL-04 pass facts for every source. + name: "a qualified name written like its alias, without facts", + query: typedTestFrom("Invoice", "x").NewQuery().SelectColumns(typedTestColumn(typedTestQualified("x", "x"), "")), + wantSQL: `SELECT "x"."x" FROM "Invoice" AS "x"`, + }, + { + // With an alias the table's own name is hidden (PostgreSQL), so the bare + // name Genre is no whole-row reference here and is not refused. + name: "a source with an alias, with a bare name equal to its table name", + query: typedTestFrom("Genre", "g").NewQuery().SelectColumns(typedTestColumn(typedTestField("Genre"), "")), + wantSQL: `SELECT "Genre" FROM "Genre" AS "g"`, + }, + }) +} + +// TestCompileTypedSQLFactsLookupsUseTheNameTheDialectWrites: under a folding +// dialect the compiler writes "total" for Total and TOTAL alike, so a facts lookup +// may match only a catalog column whose own name is "total". A catalog that holds +// Total (NOT NULL) and total (nullable) must never lend Total's NOT NULL to the +// column the server will sort, or the NULLS clause is dropped and the nullable +// column sorts with NULL on the wrong side. +func TestCompileTypedSQLFactsLookupsUseTheNameTheDialectWrites(t *testing.T) { + folding := newFoldingFakeTypedDialect() + colliding := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + {Name: "invoice"}: {Columns: []typedColumnFact{{Name: "Total", NotNull: true}, {Name: "total"}, {Name: "secret"}}}, + }} + invoice := func() dal.IQueryBuilder { return typedTestFrom("Invoice", "").NewQuery() } + runTypedGoldens(t, []typedGolden{ + { + name: "ORDER BY keeps the NULLS clause of the nullable column the server sorts", + dialect: folding, + facts: colliding, + query: invoice().OrderBy(dal.Ascending(typedTestField("total"))).SelectColumns(typedTestColumn(typedTestField("total"), "")), + wantSQL: `SELECT "total" FROM "invoice" ORDER BY "total" ASC NULLS FIRST`, + }, + { + name: "the same, spelled as the mixed-case catalog name", + dialect: folding, + facts: colliding, + query: invoice().OrderBy(dal.Descending(typedTestField("Total"))).SelectColumns(typedTestColumn(typedTestField("total"), "")), + wantSQL: `SELECT "total" FROM "invoice" ORDER BY "total" DESC NULLS LAST`, + }, + }) + t.Run("a select-all that would write a name the catalog does not hold is refused", func(t *testing.T) { + q := invoice().SelectColumns(dal.AllColumnsExcept("secret")) + expectTypedUnsupported(t, q, folding, colliding, `catalog column "Total"`) + }) + t.Run("a select-all over names that are their own folded form compiles", func(t *testing.T) { + lower := typedCatalogFacts{Fold: strings.ToLower, Sources: map[typedSourceName]typedSourceFacts{ + {Name: "invoice"}: {Columns: []typedColumnFact{{Name: "total"}, {Name: "secret"}}}, + }} + typedGolden{ + dialect: folding, + facts: lower, + query: invoice().SelectColumns(dal.AllColumnsExcept("secret")), + wantSQL: `SELECT "total" FROM "invoice"`, + }.run(t) + }) +}