diff --git a/typed_dialect.go b/typed_dialect.go new file mode 100644 index 0000000..37d48e0 --- /dev/null +++ b/typed_dialect.go @@ -0,0 +1,358 @@ +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. 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 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. 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 +// 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. +// - 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, 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. 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 { + // 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) 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. 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 +} + +func (f typedCatalogFacts) fold(name string) string { + if f.Fold == nil { + return name + } + 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 { + return typedColumnFact{}, false + } + key := f.fold(column) + for _, candidate := range source.Columns { + if 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..64f8413 --- /dev/null +++ b/typed_dialect_fake_test.go @@ -0,0 +1,296 @@ +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, 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 + 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 + emptyInOverride func(negated bool) string + limitOffsetOverride func(limit, offset int) (string, []any) +} + +func newFakeTypedDialect() *fakeTypedDialect { + return &fakeTypedDialect{ + style: typedPlaceholderStyle{Prefix: "$", IdentQuote: '"'}, + caps: fullFakeTypedCapabilities(), + identLimit: 63, + } +} + +// 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, + 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) { + if d.bindOverride != nil { + return d.bindOverride(value) + } + 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) { + if d.limitOffsetOverride != nil { + return d.limitOffsetOverride(limit, offset) + } + 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 { + if d.orderItemOverride != nil { + return d.orderItemOverride(expression, descending, notNull) + } + 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 { + 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)" + } + return aggregate +} + +func (d *fakeTypedDialect) emptyIn(negated bool) string { + if d.emptyInOverride != nil { + return d.emptyInOverride(negated) + } + 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..d7df596 --- /dev/null +++ b/typed_dialect_test.go @@ -0,0 +1,270 @@ +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) + } + }) + // 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 { + 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..019977e --- /dev/null +++ b/typed_placeholders.go @@ -0,0 +1,136 @@ +package dalgo2sql + +import ( + "errors" + "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. +// +// 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) + inIdentifier := false + markers := 0 + for i := 0; i < len(text); i++ { + b := text[i] + switch { + case b == style.IdentQuote: + inIdentifier = !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) + } + 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 +} + +// 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. +// +// 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 { + 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 new file mode 100644 index 0000000..d4865cc --- /dev/null +++ b/typed_placeholders_test.go @@ -0,0 +1,146 @@ +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"}, + {"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) { + 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) + } + }) + } +} + +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 new file mode 100644 index 0000000..10015bc --- /dev/null +++ b/typed_sql.go @@ -0,0 +1,1401 @@ +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, 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 +// 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 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. +// +// 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 +// 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), 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") + } + 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") + } + // 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") + } + 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 + } + 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) + 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 + } + return text, args, nil +} + +// 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. 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 { + return errTypedInvalidAggregation + } + 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 +} + +// 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. +// +// 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 +// (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 +} + +// 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 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. 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 +// 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 + 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 + // 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 +// 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 := typedAliases(columns) + + 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, 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 + } + 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 { + // 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 + } + quoted, err := c.quote(column.Name) + if err != nil { + return nil, err + } + items = append(items, typedSelectItem{expression: dal.NewFieldRef("", column.Name), sql: quoted, output: quoted, column: 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) { + 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.expression, item.sql, item.args = column.Expression, sql, args + name := column.Alias + if name == "" { + if isField { + item.output = item.column + 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 + item.output = 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 +} + +// 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. +// +// 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. 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 + } + 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) + } + if err := typedCheckOrderByName(expression, sql, 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)) + } + 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 +} + +// 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 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, written string, items []typedSelectItem) error { + field, ok := expression.(dal.FieldRef) + if !ok || field.Source() != "" { + return nil + } + for _, item := range items { + 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 +} + +// 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 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) { + for i, item := range items { + 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 { + 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. +// 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 { + 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) { + // 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 + } + if field.Source() == "" { + 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. +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 + } + 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: + 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 " + } + 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) { + 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 { + 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 + } + return quotient, args, nil + } + 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), 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 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 +// 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: + 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 { + 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)) + } + 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_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 new file mode 100644 index 0000000..979e9a6 --- /dev/null +++ b/typed_sql_errors_test.go @@ -0,0 +1,964 @@ +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" } + +// 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() } + 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 + stableFirstLast.caps.Aggregate.Last = true + noMin := newFakeTypedDialect() + noMin.caps.Aggregate.Min = false + 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"), "")) + } + // 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 + 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", + }, + { + "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", + }, + // 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)"`, + }, + // 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"}, + {"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"}, + // 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()"}, + { + "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) { + 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"}, + // 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"}, + {"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) + }) + } +} + +// TestCompileTypedSQLErrorsCarryNoConstant: DALgo's grouping validation writes the +// query's own expressions into its message, constants included. A server logs +// 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 { + 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 { + 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) + } + }) +} + +// 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 + 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")) + // 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", + }, + { + // 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 + ")" } + }, + dividedSameText, "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 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, + 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{ + {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"}, + } + 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") + }) + } + // 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) + } + }) +} + +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) + } +} + +// 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_property_test.go b/typed_sql_property_test.go new file mode 100644 index 0000000..e641713 --- /dev/null +++ b/typed_sql_property_test.go @@ -0,0 +1,458 @@ +package dalgo2sql + +import ( + "fmt" + "math" + "math/rand/v2" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "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. +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 +} + +// 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 { + hostile := func() string { + var b strings.Builder + 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: 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, + 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")) +} + +// typedBoundArgument is the argument dialect sends beside the text for value. +func typedBoundArgument(t typedProofReporter, 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 typedProofReporter, 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 typedProofReporter, 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} { + // 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) + } + } + 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 typedProofReporter, 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) + } +} + +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 + 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) + }) + } +} + +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(t, newFakeTypedDialect()), + }.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) + } +} + +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) + } +} + +// 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 new file mode 100644 index 0000000..09f1edd --- /dev/null +++ b/typed_sql_test.go @@ -0,0 +1,1015 @@ +package dalgo2sql + +import ( + "strings" + "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: "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(). + 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{ + // 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{ + { + 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 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"), "")), + 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}, + }, + }) +} + +// 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}, + }, + { + // 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}, + }, + }) +} + +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 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 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"))) + 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}, + }, + { + 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"`, + }, + }) +} + +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). + 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 } + +// 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) + }) +}