Skip to content

Commit 9c524ec

Browse files
radimclaude
andcommitted
test: cover ORM variant collapse and reshape AST transforms
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8d54079 commit 9c524ec

3 files changed

Lines changed: 275 additions & 5 deletions

File tree

cluster_test.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ func TestGroupOrdering(t *testing.T) {
8282
}
8383
}
8484

85-
// Documents MVP behavior: alias stripping is deferred, so ORM variants
86-
// that differ only in aliases currently produce more than one cluster.
87-
// When Phase 0.x lands alias-strip, tighten this to `== 1`.
85+
// Alias-only variants collapse (reshape strips decorative aliases);
86+
// the LIMIT variant stays in its own cluster because LIMIT changes plan
87+
// shape and LIMIT subsumption is intentionally out of scope.
8888
func TestGroupORMVariantsCurrentBehavior(t *testing.T) {
8989
in := []Query{
9090
{Raw: "SELECT id, name FROM users WHERE id = $1", Calls: 1},
@@ -95,8 +95,8 @@ func TestGroupORMVariantsCurrentBehavior(t *testing.T) {
9595
if err != nil {
9696
t.Fatal(err)
9797
}
98-
if len(out) < 2 {
99-
t.Errorf("expected >= 2 clusters in MVP (alias strip deferred), got %d", len(out))
98+
if len(out) != 2 {
99+
t.Errorf("expected 2 clusters (alias variants collapse, LIMIT stays separate), got %d", len(out))
100100
}
101101
total := int64(0)
102102
for _, c := range out {
@@ -107,6 +107,27 @@ func TestGroupORMVariantsCurrentBehavior(t *testing.T) {
107107
}
108108
}
109109

110+
// Safe ORM variants — alias-only, optional AS, AND-predicate reorder —
111+
// must collapse to a single canonical fingerprint.
112+
func TestGroupORMVariantsCollapse(t *testing.T) {
113+
in := []Query{
114+
{Raw: "SELECT id, name FROM users WHERE id = $1 AND status = $2", Calls: 1},
115+
{Raw: "SELECT id, name FROM users WHERE status = $2 AND id = $1", Calls: 1},
116+
{Raw: "SELECT u.id, u.name FROM users u WHERE u.id = $1 AND u.status = $2", Calls: 1},
117+
{Raw: "SELECT u.id, u.name FROM users AS u WHERE u.status = $2 AND u.id = $1", Calls: 1},
118+
}
119+
out, err := Group(in)
120+
if err != nil {
121+
t.Fatal(err)
122+
}
123+
if len(out) != 1 {
124+
t.Fatalf("expected 1 cluster, got %d: %+v", len(out), out)
125+
}
126+
if out[0].TotalCalls != int64(len(in)) {
127+
t.Errorf("TotalCalls = %d, want %d", out[0].TotalCalls, len(in))
128+
}
129+
}
130+
110131
func TestGroupUnparseable(t *testing.T) {
111132
in := []Query{
112133
{Raw: "SELECT FROM WHERE", Calls: 5},

normalize_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,82 @@ func TestNormalizeInvalid(t *testing.T) {
4343
t.Error("expected error on invalid SQL")
4444
}
4545
}
46+
47+
// Golden ORM fixtures: each group's variants must normalize to one canonical
48+
// form (decorative aliases stripped, optional AS absorbed by the deparser,
49+
// AND-predicates sorted), and distinct groups must not collide.
50+
func TestNormalizeORMFixtures(t *testing.T) {
51+
groups := [][]string{
52+
// Rails .where(id: ...) — alias, AS, renamed-alias variants
53+
{
54+
"SELECT id, name FROM users WHERE id = $1",
55+
"SELECT u.id, u.name FROM users u WHERE u.id = $1",
56+
"SELECT u.id, u.name FROM users AS u WHERE u.id = $1",
57+
"SELECT users_alias.id, users_alias.name FROM users users_alias WHERE users_alias.id = $1",
58+
},
59+
// Rails/ActiveRecord .joins(:posts) — aliased JOIN + AND reorder
60+
{
61+
"SELECT u.id, p.title FROM users u JOIN posts p ON p.user_id = u.id WHERE u.id = $1 AND p.published = $2",
62+
"SELECT u.id, p.title FROM users u JOIN posts p ON p.user_id = u.id WHERE p.published = $2 AND u.id = $1",
63+
"SELECT u.id, p.title FROM users AS u INNER JOIN posts AS p ON p.user_id = u.id WHERE u.id = $1 AND p.published = $2",
64+
},
65+
// Soft-delete scope with AND reorder
66+
{
67+
"SELECT id FROM users WHERE tenant_id = $1 AND deleted_at IS NULL",
68+
"SELECT u.id FROM users u WHERE u.tenant_id = $1 AND u.deleted_at IS NULL",
69+
"SELECT u.id FROM users AS u WHERE u.deleted_at IS NULL AND u.tenant_id = $1",
70+
},
71+
// UPDATE with AND reorder
72+
{
73+
"UPDATE users SET name = $1 WHERE id = $2 AND tenant_id = $3",
74+
"UPDATE users SET name = $1 WHERE tenant_id = $3 AND id = $2",
75+
},
76+
// DELETE with alias + AND reorder
77+
{
78+
"DELETE FROM users WHERE id = $1 AND tenant_id = $2",
79+
"DELETE FROM users u WHERE u.id = $1 AND u.tenant_id = $2",
80+
"DELETE FROM users WHERE tenant_id = $2 AND id = $1",
81+
},
82+
// Aggregate with alias + AND reorder (Prisma/Sequelize count pattern)
83+
{
84+
"SELECT COUNT(*) FROM orders WHERE status = $1 AND user_id = $2",
85+
"SELECT COUNT(*) FROM orders o WHERE o.status = $1 AND o.user_id = $2",
86+
"SELECT COUNT(*) FROM orders WHERE user_id = $2 AND status = $1",
87+
},
88+
// INSERT with RETURNING — separate canonical form from plain INSERT
89+
{
90+
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
91+
},
92+
// Prisma findUnique-style alias variants
93+
{
94+
"SELECT id, email, created_at FROM users WHERE email = $1 LIMIT 1",
95+
"SELECT u.id, u.email, u.created_at FROM users u WHERE u.email = $1 LIMIT 1",
96+
},
97+
}
98+
99+
canonicals := make(map[string]int)
100+
total := 0
101+
for gi, group := range groups {
102+
var first string
103+
for vi, raw := range group {
104+
got, err := Normalize(raw)
105+
if err != nil {
106+
t.Fatalf("group %d variant %d: Normalize(%q) error: %v", gi, vi, raw, err)
107+
}
108+
if vi == 0 {
109+
first = got
110+
if prev, dup := canonicals[got]; dup {
111+
t.Errorf("group %d canonical %q collides with group %d", gi, got, prev)
112+
}
113+
canonicals[got] = gi
114+
} else if got != first {
115+
t.Errorf("group %d variant %d diverged:\n first: %q\n this: %q\n input: %q",
116+
gi, vi, first, got, raw)
117+
}
118+
total++
119+
}
120+
}
121+
if total < 20 {
122+
t.Errorf("fixture count = %d, want >= 20 per Phase 0.x target", total)
123+
}
124+
}

reshape_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package qshape
2+
3+
import "testing"
4+
5+
func TestReshapeStripsSingleTableAlias(t *testing.T) {
6+
got, err := Normalize("SELECT u.id, u.name FROM users u WHERE u.id = $1")
7+
if err != nil {
8+
t.Fatal(err)
9+
}
10+
want := "SELECT id, name FROM users WHERE id = $1"
11+
if got != want {
12+
t.Errorf("got: %q\nwant: %q", got, want)
13+
}
14+
}
15+
16+
func TestReshapeLeavesUnaliasedUnchanged(t *testing.T) {
17+
got, err := Normalize("SELECT id FROM users WHERE id = $1")
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
if got != "SELECT id FROM users WHERE id = $1" {
22+
t.Errorf("unexpected rewrite: %q", got)
23+
}
24+
}
25+
26+
func TestReshapeORMVariantsCollapse(t *testing.T) {
27+
fpA, err := Fingerprint("SELECT id, name FROM users WHERE id = $1")
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
fpB, err := Fingerprint("SELECT u.id, u.name FROM users u WHERE u.id = $1")
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
if fpA != fpB {
36+
t.Errorf("aliased and unaliased should fingerprint the same:\n bare: %s\n aliased: %s", fpA, fpB)
37+
}
38+
}
39+
40+
func TestReshapeSortsFlatAndTree(t *testing.T) {
41+
// The two queries differ only in AND-conjunct ordering; they should
42+
// reshape to the same form
43+
a, err := Normalize("SELECT id FROM users WHERE a = $1 AND b = $2")
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
b, err := Normalize("SELECT id FROM users WHERE b = $2 AND a = $1")
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
if a != b {
52+
t.Errorf("reordered AND conjuncts did not collapse:\n a: %q\n b: %q", a, b)
53+
}
54+
}
55+
56+
func TestReshapeIdempotent(t *testing.T) {
57+
cases := []string{
58+
"SELECT u.id FROM users u WHERE u.id = $1 AND u.tenant_id = $2",
59+
"SELECT id FROM users",
60+
"SELECT id FROM users WHERE b = $1 AND a = $2 AND c = $3",
61+
"UPDATE users SET name = $1 WHERE id = $2",
62+
"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id",
63+
"SELECT a.id FROM users a JOIN users b ON a.id = b.parent_id",
64+
"WITH recent AS (SELECT id FROM users) SELECT id FROM recent",
65+
}
66+
for _, in := range cases {
67+
first, err := Normalize(in)
68+
if err != nil {
69+
t.Fatalf("%q: %v", in, err)
70+
}
71+
second, err := Normalize(first)
72+
if err != nil {
73+
t.Fatalf("%q (second pass): %v", first, err)
74+
}
75+
if first != second {
76+
t.Errorf("not idempotent for %q:\n first: %q\n second: %q", in, first, second)
77+
}
78+
}
79+
}
80+
81+
func TestReshapeJoinStripsDecorativeAliases(t *testing.T) {
82+
got, err := Normalize("SELECT u.id, o.total FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE u.tenant = $1")
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
want := "SELECT id, total FROM users JOIN orders ON user_id = id WHERE tenant = $1"
87+
if got != want {
88+
t.Errorf("got: %q\nwant: %q", got, want)
89+
}
90+
}
91+
92+
func TestReshapeSelfJoinPreservesAliases(t *testing.T) {
93+
got, err := Normalize("SELECT a.id FROM users a INNER JOIN users b ON a.id = b.parent_id")
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
// Both aliases must stay — otherwise the join is ambiguous
98+
if got != "SELECT a.id FROM users a JOIN users b ON a.id = b.parent_id" {
99+
t.Errorf("self-join aliases got stripped: %q", got)
100+
}
101+
}
102+
103+
func TestReshapeRangeSubselectAliasRequired(t *testing.T) {
104+
// SQL syntactically requires the alias on a subselect in FROM
105+
got, err := Normalize("SELECT s.id FROM (SELECT id FROM users) s")
106+
if err != nil {
107+
t.Fatal(err)
108+
}
109+
if got != "SELECT s.id FROM (SELECT id FROM users) s" {
110+
t.Errorf("subselect alias got stripped: %q", got)
111+
}
112+
}
113+
114+
func TestReshapeUpdateStripsAlias(t *testing.T) {
115+
got, err := Normalize("UPDATE users u SET name = $1 WHERE u.id = $2")
116+
if err != nil {
117+
t.Fatal(err)
118+
}
119+
if got != "UPDATE users SET name = $1 WHERE id = $2" {
120+
t.Errorf("got: %q", got)
121+
}
122+
}
123+
124+
func TestReshapeDeleteStripsAlias(t *testing.T) {
125+
got, err := Normalize("DELETE FROM users u WHERE u.id = $1")
126+
if err != nil {
127+
t.Fatal(err)
128+
}
129+
if got != "DELETE FROM users WHERE id = $1" {
130+
t.Errorf("got: %q", got)
131+
}
132+
}
133+
134+
func TestReshapeCorrelatedSubqueryRewritesOuterRef(t *testing.T) {
135+
got, err := Normalize("SELECT u.id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)")
136+
if err != nil {
137+
t.Fatal(err)
138+
}
139+
// Outer `u` stripped; correlated `u.id` inside subquery must also
140+
// be rewritten, or the deparsed SQL references a missing alias
141+
want := "SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = id)"
142+
if got != want {
143+
t.Errorf("got: %q\nwant: %q", got, want)
144+
}
145+
}
146+
147+
func TestReshapeCTEGetsOwnScope(t *testing.T) {
148+
got, err := Normalize("WITH recent AS (SELECT u.id FROM users u WHERE u.created_at > $1) SELECT r.id FROM recent r")
149+
if err != nil {
150+
t.Fatal(err)
151+
}
152+
want := "WITH recent AS (SELECT id FROM users WHERE created_at > $1) SELECT id FROM recent"
153+
if got != want {
154+
t.Errorf("got: %q\nwant: %q", got, want)
155+
}
156+
}
157+
158+
func TestReshapeUnionBothArms(t *testing.T) {
159+
a, err := Normalize("SELECT u.id FROM users u UNION SELECT o.user_id FROM orders o")
160+
if err != nil {
161+
t.Fatal(err)
162+
}
163+
b, err := Normalize("SELECT id FROM users UNION SELECT user_id FROM orders")
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
if a != b {
168+
t.Errorf("UNION arms did not collapse to the same form:\n a: %q\n b: %q", a, b)
169+
}
170+
}

0 commit comments

Comments
 (0)