diff --git a/internal/difftest/normalize.go b/internal/difftest/normalize.go new file mode 100644 index 0000000..c5d2cd6 --- /dev/null +++ b/internal/difftest/normalize.go @@ -0,0 +1,91 @@ +package difftest + +import ( + "fmt" + "sort" + "strings" + + "github.com/aybavs/sql-query-engine/internal/value" +) + +// nullCell is a sentinel no literal in the generated grammar can produce, so a +// text value never masquerades as NULL. +const nullCell = "∅" + +// normalizeEngineRow renders one engine row as comparable cells. Cells carry a +// type tag so the text "35" cannot compare equal to the number 35. +func normalizeEngineRow(r value.Row) []string { + out := make([]string, len(r)) + for i, v := range r { + switch { + case v.IsNull(): + out[i] = nullCell + case v.Type == value.TInt: + out[i] = num(float64(v.I)) + case v.Type == value.TFloat: + out[i] = num(v.F) + case v.Type == value.TBool: + out[i] = fmt.Sprintf("b:%t", v.B) + default: + out[i] = "s:" + v.S + } + } + return out +} + +// normalizeOracleRow renders one SQLite driver row the same way. +func normalizeOracleRow(r Row) []string { + out := make([]string, len(r)) + for i, cell := range r { + switch v := cell.(type) { + case nil: + out[i] = nullCell + case int64: + out[i] = num(float64(v)) + case float64: + out[i] = num(v) + case bool: + out[i] = fmt.Sprintf("b:%t", v) + case []byte: + out[i] = "s:" + string(v) + case string: + out[i] = "s:" + v + default: + out[i] = fmt.Sprintf("?:%v", v) + } + } + return out +} + +// num renders a number so an integer and an equal float compare the same. +// SQLite returns an average as a float where this engine may return an int, and +// the two can disagree in the last bits; six significant digits absorb that +// noise while still separating genuinely different values. +func num(f float64) string { return fmt.Sprintf("n:%.6g", f) } + +// canonical sorts rows so two result sets can be compared as multisets. Row +// order is unspecified in SQL without ORDER BY, so the comparison must not +// depend on it — but duplicates still have to match. +func canonical(rows [][]string) []string { + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = strings.Join(r, "\x1f") + } + sort.Strings(out) + return out +} + +// Compare reports whether two result sets hold the same rows, ignoring order +// but respecting duplicates. +func Compare(engine, oracle [][]string) error { + a, b := canonical(engine), canonical(oracle) + if len(a) != len(b) { + return fmt.Errorf("row count: engine %d, sqlite %d\n engine: %v\n sqlite: %v", len(a), len(b), a, b) + } + for i := range a { + if a[i] != b[i] { + return fmt.Errorf("row %d differs:\n engine: %s\n sqlite: %s", i, a[i], b[i]) + } + } + return nil +} diff --git a/internal/difftest/normalize_test.go b/internal/difftest/normalize_test.go new file mode 100644 index 0000000..b60a636 --- /dev/null +++ b/internal/difftest/normalize_test.go @@ -0,0 +1,86 @@ +package difftest + +import ( + "testing" + + "github.com/aybavs/sql-query-engine/internal/value" +) + +func TestNormalizeMatchesIntAndFloat(t *testing.T) { + eng := normalizeEngineRow(value.Row{value.Int64(35)}) + orc := normalizeOracleRow(Row{float64(35)}) + if eng[0] != orc[0] { + t.Fatalf("int 35 = %q but float 35.0 = %q; they must normalize alike", eng[0], orc[0]) + } +} + +func TestNormalizeNull(t *testing.T) { + eng := normalizeEngineRow(value.Row{value.NullOf(value.TInt)}) + orc := normalizeOracleRow(Row{nil}) + if eng[0] != orc[0] { + t.Fatalf("NULL normalized differently: %q vs %q", eng[0], orc[0]) + } +} + +func TestNormalizeTextIsNotConfusedWithNull(t *testing.T) { + eng := normalizeEngineRow(value.Row{value.Text("NULL")}) + orc := normalizeOracleRow(Row{nil}) + if eng[0] == orc[0] { + t.Fatal("the text 'NULL' must not normalize to the NULL sentinel") + } +} + +func TestNormalizeTextIsNotConfusedWithNumber(t *testing.T) { + eng := normalizeEngineRow(value.Row{value.Text("35")}) + orc := normalizeOracleRow(Row{int64(35)}) + if eng[0] == orc[0] { + t.Fatal("the text '35' must not normalize to the number 35") + } +} + +func TestNormalizeAbsorbsFloatNoise(t *testing.T) { + // An average computed two different ways can differ in the last bits. + a := normalizeEngineRow(value.Row{value.Float64(35.0)}) + b := normalizeOracleRow(Row{35.000000000000004}) + if a[0] != b[0] { + t.Fatalf("float noise must not read as a difference: %q vs %q", a[0], b[0]) + } +} + +func TestNormalizeKeepsRealNumericDifference(t *testing.T) { + a := normalizeEngineRow(value.Row{value.Float64(35.0)}) + b := normalizeOracleRow(Row{35.1}) + if a[0] == b[0] { + t.Fatal("35.0 and 35.1 are genuinely different and must not normalize alike") + } +} + +func TestCompareIgnoresRowOrder(t *testing.T) { + a := [][]string{{"1"}, {"2"}} + b := [][]string{{"2"}, {"1"}} + if err := Compare(a, b); err != nil { + t.Fatalf("row order must not matter: %v", err) + } +} + +func TestCompareDetectsDifference(t *testing.T) { + a := [][]string{{"1"}, {"2"}} + b := [][]string{{"1"}, {"3"}} + if err := Compare(a, b); err == nil { + t.Fatal("differing multisets must be reported") + } +} + +func TestCompareDetectsDuplicateCount(t *testing.T) { + a := [][]string{{"1"}, {"1"}} + b := [][]string{{"1"}} + if err := Compare(a, b); err == nil { + t.Fatal("duplicate counts must matter") + } +} + +func TestCompareEmptyResultsMatch(t *testing.T) { + if err := Compare(nil, nil); err != nil { + t.Fatalf("two empty result sets must match: %v", err) + } +}