From 0bf43f709ddc0028a88ee0eb88b96dd954b915e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aybars=20Mete=20Kele=C5=9F?= Date: Tue, 4 Aug 2026 13:56:45 +0300 Subject: [PATCH] test: add an in-memory SQLite oracle over the same fixtures --- go.mod | 16 +++- go.sum | 50 ++++++++++++ internal/catalog/catalog.go | 22 +++++- internal/catalog/catalog_test.go | 10 +++ internal/difftest/oracle.go | 128 +++++++++++++++++++++++++++++++ internal/difftest/oracle_test.go | 73 ++++++++++++++++++ 6 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 internal/difftest/oracle.go create mode 100644 internal/difftest/oracle_test.go diff --git a/go.mod b/go.mod index 9313d1a..8cd2f0b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module github.com/aybavs/sql-query-engine -go 1.22 +go 1.25.0 + +require modernc.org/sqlite v1.56.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1932692 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index d443f70..d264a11 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -1,7 +1,11 @@ // Package catalog holds table schemas and resolves table/column references. package catalog -import "github.com/aybavs/sql-query-engine/internal/value" +import ( + "sort" + + "github.com/aybavs/sql-query-engine/internal/value" +) type Column struct { Name string @@ -35,3 +39,19 @@ func (c *Catalog) Table(name string) (*Table, bool) { t, ok := c.tables[name] return t, ok } + +// Tables returns every registered table, ordered by name so callers that +// iterate the catalog behave deterministically. +func (c *Catalog) Tables() []*Table { + names := make([]string, 0, len(c.tables)) + for n := range c.tables { + names = append(names, n) + } + sort.Strings(names) + + out := make([]*Table, 0, len(names)) + for _, n := range names { + out = append(out, c.tables[n]) + } + return out +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 439fede..876f77f 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -31,3 +31,13 @@ func TestCatalogLookup(t *testing.T) { t.Fatal("unknown column should not resolve") } } + +func TestTablesIsSortedAndComplete(t *testing.T) { + c := New() + c.Add(&Table{Name: "users"}) + c.Add(&Table{Name: "orders"}) + got := c.Tables() + if len(got) != 2 || got[0].Name != "orders" || got[1].Name != "users" { + t.Fatalf("Tables() = %v, want [orders users]", got) + } +} diff --git a/internal/difftest/oracle.go b/internal/difftest/oracle.go new file mode 100644 index 0000000..a8a9253 --- /dev/null +++ b/internal/difftest/oracle.go @@ -0,0 +1,128 @@ +// Package difftest compares this engine against SQLite on generated queries. +// It is test-only: nothing outside its own tests imports it, so the SQLite +// driver never enters the engine's build. +package difftest + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + + "github.com/aybavs/sql-query-engine/internal/catalog" + "github.com/aybavs/sql-query-engine/internal/csv" + "github.com/aybavs/sql-query-engine/internal/value" + + _ "modernc.org/sqlite" +) + +// Row is one result row of driver values (int64, float64, string, or nil). +type Row []any + +// Oracle is an in-memory SQLite database loaded with the same fixture data as +// the engine under test, used as the source of truth for query results. +type Oracle struct{ db *sql.DB } + +func NewOracle(cat *catalog.Catalog, dataDir string) (*Oracle, error) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + return nil, err + } + o := &Oracle{db: db} + for _, t := range cat.Tables() { + if err := o.load(t, dataDir); err != nil { + db.Close() + return nil, err + } + } + return o, nil +} + +func (o *Oracle) load(t *catalog.Table, dataDir string) error { + cols := make([]string, len(t.Columns)) + for i, c := range t.Columns { + cols[i] = c.Name + " " + sqliteType(c.Type) + } + create := fmt.Sprintf("CREATE TABLE %s (%s)", t.Name, strings.Join(cols, ", ")) + if _, err := o.db.Exec(create); err != nil { + return fmt.Errorf("create %s: %w", t.Name, err) + } + + rows, err := csv.Read(filepath.Join(dataDir, t.File), t.Columns) + if err != nil { + return err + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(t.Columns)), ",") + insert := fmt.Sprintf("INSERT INTO %s VALUES (%s)", t.Name, placeholders) + for _, r := range rows { + args := make([]any, len(r)) + for i, v := range r { + args[i] = driverValue(v) + } + if _, err := o.db.Exec(insert, args...); err != nil { + return fmt.Errorf("insert into %s: %w", t.Name, err) + } + } + return nil +} + +// driverValue converts an engine value into what the SQLite driver expects, +// preserving NULL rather than substituting a zero value. +func driverValue(v value.Value) any { + if v.IsNull() { + return nil + } + switch v.Type { + case value.TInt: + return v.I + case value.TFloat: + return v.F + case value.TBool: + return v.B + default: + return v.S + } +} + +func sqliteType(t value.Type) string { + switch t { + case value.TInt: + return "INTEGER" + case value.TFloat: + return "REAL" + case value.TBool: + return "INTEGER" + default: + return "TEXT" + } +} + +// Query runs a statement and returns its rows as driver values. +func (o *Oracle) Query(query string) ([]Row, error) { + rs, err := o.db.Query(query) + if err != nil { + return nil, err + } + defer rs.Close() + + cols, err := rs.Columns() + if err != nil { + return nil, err + } + + var out []Row + for rs.Next() { + cells := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range cells { + ptrs[i] = &cells[i] + } + if err := rs.Scan(ptrs...); err != nil { + return nil, err + } + out = append(out, Row(cells)) + } + return out, rs.Err() +} + +func (o *Oracle) Close() error { return o.db.Close() } diff --git a/internal/difftest/oracle_test.go b/internal/difftest/oracle_test.go new file mode 100644 index 0000000..4dec278 --- /dev/null +++ b/internal/difftest/oracle_test.go @@ -0,0 +1,73 @@ +package difftest + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aybavs/sql-query-engine/internal/catalog" + "github.com/aybavs/sql-query-engine/internal/value" +) + +func fixture(t *testing.T) (*catalog.Catalog, string) { + t.Helper() + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "users.csv"), []byte("1,alice,30\n2,bob,\n"), 0o644) + cat := catalog.New() + cat.Add(&catalog.Table{Name: "users", File: "users.csv", Columns: []catalog.Column{ + {Name: "id", Type: value.TInt}, + {Name: "name", Type: value.TText}, + {Name: "age", Type: value.TInt}, + }}) + return cat, dir +} + +func TestOracleLoadsAndQueries(t *testing.T) { + cat, dir := fixture(t) + o, err := NewOracle(cat, dir) + if err != nil { + t.Fatalf("NewOracle: %v", err) + } + defer o.Close() + + rows, err := o.Query("SELECT name FROM users ORDER BY name") + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(rows) != 2 { + t.Fatalf("rows = %v, want 2", rows) + } + if s, ok := rows[0][0].(string); !ok || s != "alice" { + t.Fatalf("first row = %v, want alice", rows[0]) + } +} + +func TestOracleKeepsEmptyFieldsNull(t *testing.T) { + cat, dir := fixture(t) + o, err := NewOracle(cat, dir) + if err != nil { + t.Fatal(err) + } + defer o.Close() + + rows, err := o.Query("SELECT age FROM users WHERE name = 'bob'") + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0][0] != nil { + t.Fatalf("bob's age = %v, want NULL", rows) + } +} + +func TestOracleReportsInvalidQuery(t *testing.T) { + cat, dir := fixture(t) + o, err := NewOracle(cat, dir) + if err != nil { + t.Fatal(err) + } + defer o.Close() + + if _, err := o.Query("SELECT nope FROM users"); err == nil { + t.Fatal("expected an error for an unknown column") + } +}