Skip to content

Commit 00775d5

Browse files
committed
feat: distinguish foreign key indexes from other non-unique indexes
Add IsForeignKeyIndex method to detect when an index's columns match a foreign key, enabling default generation of FK-based list queries while keeping non-FK index queries opt-in. Update template to conditionally include list queries based on FK index status or explicit opt-in.
1 parent 1ed3bb8 commit 00775d5

8 files changed

Lines changed: 132 additions & 7 deletions

File tree

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ A CLI tool that generates [sqlc](https://sqlc.dev)-compatible SQL queries from y
1212
## Features
1313

1414
- Generate CRUD queries (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) from a database schema catalog
15-
- Primary key CRUD and List operations generated by default — no configuration needed
16-
- Opt-in to additional queries (Copy, FK joins, non-unique index queries) via `queries` allowlist
15+
- Primary key CRUD and FK-based List operations generated by default — no configuration needed
16+
- Opt-in to additional queries (Copy, FK joins, non-FK index queries) via `queries` allowlist
1717
- Configurable via YAML — shares the same `sqlc.yaml` configuration file
1818
- Works as a standalone CLI or as part of a CI/CD pipeline
1919
- Supports custom query templates
@@ -67,15 +67,16 @@ sql:
6767
6868
### Default queries (always generated)
6969
70-
Primary key CRUD operations, List queries, and their Exec/Batch variants are
71-
generated automatically for every table:
70+
Primary key CRUD operations, List queries (including FK-index-based list
71+
queries), and their Exec/Batch variants are generated automatically for every
72+
table:
7273
7374
| Query | Description |
7475
| ------------------------- | ------------------------------------------ |
7576
| `Get<Table>` | Select a row by primary key |
7677
| `BatchGet<Tables>` | Batch select rows by primary key |
7778
| `List<Tables>` | Paginated list with filtering |
78-
| `List<Tables>By<Columns>` | Paginated list by non-unique index |
79+
| `List<Tables>By<Columns>` | Paginated list by foreign key index |
7980
| `Insert<Table>` | Insert a row |
8081
| `ExecInsert<Table>` | Insert a row (exec, returns affected rows) |
8182
| `BatchInsert<Tables>` | Batch insert rows |
@@ -99,6 +100,7 @@ These queries are only generated when explicitly listed in `options.queries`:
99100
| `Get<Table>With<Related>` | Select with FK join |
100101
| `BatchGet<Tables>With<Related>` | Batch select with FK join |
101102
| `Get<Table>By<Columns>` | Select by non-PK unique index |
103+
| `List<Tables>By<Columns>` | Paginated list by non-FK non-unique index|
102104
| `Update<Tables>By<Columns>` | Update by non-unique index |
103105
| `Delete<Tables>By<Columns>` | Delete by non-unique index |
104106

internal/sqlc/catalog.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,27 @@ func (x *Table) GetNonPrimaryKeyColumns() []Column {
143143
return columns
144144
}
145145

146+
// IsForeignKeyIndex checks if the given index's columns exactly match
147+
// any foreign key's columns on this table (order-independent).
148+
func (x *Table) IsForeignKeyIndex(index *Index) bool {
149+
indexCols := make([]string, len(index.Parts))
150+
for i, part := range index.Parts {
151+
indexCols[i] = part.Column
152+
}
153+
slices.Sort(indexCols)
154+
155+
for _, fk := range x.ForeignKeys {
156+
fkCols := make([]string, len(fk.Columns))
157+
copy(fkCols, fk.Columns)
158+
slices.Sort(fkCols)
159+
160+
if slices.Equal(indexCols, fkCols) {
161+
return true
162+
}
163+
}
164+
return false
165+
}
166+
146167
// Column represents a table column with its name, data type, and nullability.
147168
type Column struct {
148169
Name string `json:"name"`

internal/sqlc/catalog_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ var _ = Describe("Catalog", func() {
8484
Expect(fkAuthor.References.Columns).To(Equal([]string{"id"}))
8585

8686
// Verify expression-based indexes
87-
Expect(postsTable.Indexes).To(HaveLen(2))
87+
Expect(postsTable.Indexes).To(HaveLen(3))
8888
exprIndex := postsTable.Indexes[1]
8989
Expect(exprIndex.Name).To(Equal("idx_posts_title_expr"))
9090
Expect(exprIndex.Parts).To(HaveLen(1))
@@ -143,9 +143,11 @@ var _ = Describe("Catalog", func() {
143143

144144
It("returns non-unique indexes", func() {
145145
keys := postsTable.GetNonUniqueIndexes()
146-
Expect(keys).To(HaveLen(1))
146+
Expect(keys).To(HaveLen(2))
147147
Expect(keys[0].Name).To(Equal("idx_posts_user_id"))
148148
Expect(keys[0].Unique).To(BeFalse())
149+
Expect(keys[1].Name).To(Equal("idx_posts_title"))
150+
Expect(keys[1].Unique).To(BeFalse())
149151
})
150152

151153
It("returns empty slice when there are no non-unique indexes", func() {
@@ -154,6 +156,34 @@ var _ = Describe("Catalog", func() {
154156
})
155157
})
156158

159+
Describe("IsForeignKeyIndex", func() {
160+
var postsTable *sqlc.Table
161+
162+
BeforeEach(func() {
163+
postsTable = &catalog.Schemas[0].Tables[1]
164+
})
165+
166+
It("returns true when index columns match a foreign key", func() {
167+
keys := postsTable.GetNonUniqueIndexes()
168+
// idx_posts_user_id matches FK fk_posts_user_id
169+
Expect(postsTable.IsForeignKeyIndex(keys[0])).To(BeTrue())
170+
})
171+
172+
It("returns false when index columns do not match any foreign key", func() {
173+
keys := postsTable.GetNonUniqueIndexes()
174+
// idx_posts_title does not match any FK
175+
Expect(postsTable.IsForeignKeyIndex(keys[1])).To(BeFalse())
176+
})
177+
178+
It("returns false when table has no foreign keys", func() {
179+
index := &sqlc.Index{
180+
Name: "idx_test",
181+
Parts: []sqlc.IndexPart{{Column: "email"}},
182+
}
183+
Expect(usersTable.IsForeignKeyIndex(index)).To(BeFalse())
184+
})
185+
})
186+
157187
Describe("GetUniqueKeys", func() {
158188
It("returns primary key and unique indexes", func() {
159189
keys := usersTable.GetUniqueKeys()

internal/sqlc/catalog_test.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@
133133
"desc": false
134134
}
135135
]
136+
},
137+
{
138+
"name": "idx_posts_title",
139+
"unique": false,
140+
"parts": [
141+
{
142+
"column": "title"
143+
}
144+
]
136145
}
137146
]
138147
}

internal/sqlc/generator.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ func (x *Generator) Generate() error {
150150
}
151151
return ""
152152
},
153+
// Foreign key index check
154+
"is_fk_index": func(table Table, index *Index) bool {
155+
return table.IsForeignKeyIndex(index)
156+
},
153157
// Query include function for opt-in queries
154158
"should_include": func(ctx Context, queryName string) bool {
155159
return ctx.Queries[queryName]

internal/sqlc/generator_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ var _ = Describe("Generator", func() {
7171
// Opt-in queries are not present without config
7272
Expect(string(content)).NotTo(ContainSubstring("name: CopyUsers :copyfrom"))
7373
}
74+
75+
// Verify posts.sql FK-based list queries
76+
for _, config := range generator.Config.SQL {
77+
path := filepath.Join(config.Queries, "posts.sql")
78+
content, err := os.ReadFile(path)
79+
Expect(err).NotTo(HaveOccurred())
80+
81+
// FK-matching index list query is present by default
82+
Expect(string(content)).To(ContainSubstring("name: ListPostsByUserId :many"))
83+
// Non-FK index list query is not present without opt-in
84+
Expect(string(content)).NotTo(ContainSubstring("name: ListPostsByTitle :many"))
85+
}
7486
})
7587

7688
When("the queries directory does not exist", func() {
@@ -106,6 +118,7 @@ var _ = Describe("Generator", func() {
106118
Options: sqlc.CodegenOptions{
107119
Queries: []string{
108120
"CopyUsers",
121+
"ListPostsByTitle",
109122
},
110123
},
111124
},
@@ -137,6 +150,18 @@ var _ = Describe("Generator", func() {
137150
// Opt-in queries that were listed are present
138151
Expect(string(content)).To(ContainSubstring("name: CopyUsers :copyfrom"))
139152
}
153+
154+
// Verify posts.sql opt-in list queries
155+
for _, config := range generator.Config.SQL {
156+
path := filepath.Join(config.Queries, "posts.sql")
157+
content, err := os.ReadFile(path)
158+
Expect(err).NotTo(HaveOccurred())
159+
160+
// FK-matching index list query is present by default
161+
Expect(string(content)).To(ContainSubstring("name: ListPostsByUserId :many"))
162+
// Non-FK index list query is present because it was opted in
163+
Expect(string(content)).To(ContainSubstring("name: ListPostsByTitle :many"))
164+
}
140165
})
141166

142167
It("only generates default queries when queries is empty", func() {
@@ -159,6 +184,16 @@ var _ = Describe("Generator", func() {
159184
// Opt-in queries are not present
160185
Expect(string(content)).NotTo(ContainSubstring("name: CopyUsers :copyfrom"))
161186
}
187+
188+
// Verify posts.sql FK-based list queries
189+
for _, config := range generator.Config.SQL {
190+
path := filepath.Join(config.Queries, "posts.sql")
191+
content, err := os.ReadFile(path)
192+
Expect(err).NotTo(HaveOccurred())
193+
194+
Expect(string(content)).To(ContainSubstring("name: ListPostsByUserId :many"))
195+
Expect(string(content)).NotTo(ContainSubstring("name: ListPostsByTitle :many"))
196+
}
162197
})
163198

164199
It("only generates default queries when codegen is nil", func() {
@@ -178,6 +213,16 @@ var _ = Describe("Generator", func() {
178213
// Default List queries are present
179214
Expect(string(content)).To(ContainSubstring("name: ListUsers :many"))
180215
}
216+
217+
// Verify posts.sql FK-based list queries
218+
for _, config := range generator.Config.SQL {
219+
path := filepath.Join(config.Queries, "posts.sql")
220+
content, err := os.ReadFile(path)
221+
Expect(err).NotTo(HaveOccurred())
222+
223+
Expect(string(content)).To(ContainSubstring("name: ListPostsByUserId :many"))
224+
Expect(string(content)).NotTo(ContainSubstring("name: ListPostsByTitle :many"))
225+
}
181226
})
182227

183228
It("handles non-existent query names gracefully", func() {
@@ -197,6 +242,16 @@ var _ = Describe("Generator", func() {
197242
// Default List queries are still present
198243
Expect(string(content)).To(ContainSubstring("name: ListUsers :many"))
199244
}
245+
246+
// Verify posts.sql FK-based list queries
247+
for _, config := range generator.Config.SQL {
248+
path := filepath.Join(config.Queries, "posts.sql")
249+
content, err := os.ReadFile(path)
250+
Expect(err).NotTo(HaveOccurred())
251+
252+
Expect(string(content)).To(ContainSubstring("name: ListPostsByUserId :many"))
253+
Expect(string(content)).NotTo(ContainSubstring("name: ListPostsByTitle :many"))
254+
}
200255
})
201256
})
202257
})

internal/sqlc/template/template.sql.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ OFFSET
299299
sqlc.narg(page_offset)::int;
300300

301301
{{range $idx, $key := .Table.GetNonUniqueIndexes}}{{- $query_name := printf "List%s%s" (table_name $.Table.Name "many") (query_index $key)}}
302+
{{- if or (is_fk_index $.Table $key) (should_include $ $query_name)}}
302303

303304
-- List{{table_name $.Table.Name "many"}}{{query_index $key}} retrieves a paginated list of rows from '{{$.Table.Name}}'{{if $key.Name}} filtered by {{$key.Name}}{{end}}.
304305
--
@@ -338,6 +339,7 @@ LIMIT
338339
sqlc.narg(page_limit)::int
339340
OFFSET
340341
sqlc.narg(page_offset)::int;
342+
{{- end}}
341343

342344
{{- $query_name := printf "Update%s%s" (table_name $.Table.Name "many") (query_index $key)}}
343345
{{- if should_include $ $query_name}}

internal/sqlc/template/template_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ var _ = Describe("Template", func() {
2424
// Pagination Functions
2525
"page_start": func(args ...any) string { return "" },
2626
"page_order": func(args ...any) string { return "" },
27+
// Foreign key index check
28+
"is_fk_index": func(args ...any) bool { return false },
2729
// Query include function
2830
"should_include": func(args ...any) bool { return false },
2931
}

0 commit comments

Comments
 (0)