@nakedev/go-scaffold 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/commands/worker.js +5 -2
- package/dist/utils/auth-patcher.js +16 -4
- package/dist/utils/hexagonal-method-patcher.js +7 -1
- package/dist/utils/main-patcher.js +18 -7
- package/dist/utils/rbac-patcher.js +15 -4
- package/package.json +1 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +25 -4
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +5 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +2 -2
- package/templates/add/rbac/docs/users.yaml.hbs +6 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +7 -4
- package/templates/create/base/.github/workflows/ci.yml.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +37 -5
- package/templates/create/base/Makefile.hbs +29 -9
- package/templates/create/base/README.md.hbs +5 -5
- package/templates/create/base/cmd/api/wiring.go.hbs +15 -13
- package/templates/create/base/internal/platform/database/database.go.hbs +8 -0
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +48 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +34 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +13 -2
- package/templates/create/features/docs/architecture.md.hbs +6 -3
- package/templates/create/features/docs/common/parameters.yaml.hbs +10 -0
- package/templates/create/features/docs/patterns.md.hbs +46 -17
- package/templates/create/features/docs/techstack.md.hbs +3 -2
- package/templates/generate/module/docs/collection.yaml.hbs +3 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +20 -4
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +5 -0
package/README.md
CHANGED
|
@@ -325,14 +325,20 @@ CRUD modules contain the starter list/get/create/update/delete methods. Lean
|
|
|
325
325
|
modules keep the endpoint surface small so it can be extended with
|
|
326
326
|
generate method.
|
|
327
327
|
|
|
328
|
-
Every generated list endpoint reads `?limit=&offset=&q=`, passes
|
|
329
|
-
`ports.ListFilter` from handler to application to repository, and answers
|
|
330
|
-
page beside the total the filter matched. The lists `add auth` and
|
|
331
|
-
own use the same contract. `FindAll` calls `dbq.Search` with no
|
|
332
|
-
|
|
328
|
+
Every generated list endpoint reads `?limit=&offset=&q=&sort=&order=`, passes
|
|
329
|
+
one `ports.ListFilter` from handler to application to repository, and answers
|
|
330
|
+
the page beside the total the filter matched. The lists `add auth` and
|
|
331
|
+
`add rbac` own use the same contract. `FindAll` calls `dbq.Search` with no
|
|
332
|
+
columns and carries a `dbq.Sort` with no columns either, so `?q=` and `?sort=`
|
|
333
|
+
are accepted and ignored until you name them in
|
|
333
334
|
`adapters/outbound/postgres/repository.go`; add further filters as fields on
|
|
334
335
|
`ListFilter` rather than as parameters.
|
|
335
336
|
|
|
337
|
+
`dbq.Sort` is why a sort name off the request never reaches the SQL — `ORDER
|
|
338
|
+
BY` takes no bound parameter, so only a key of its `Columns` map is ever
|
|
339
|
+
interpolated — and it adds the tiebreaker and `NULLS LAST` that every stable
|
|
340
|
+
paged list needs.
|
|
341
|
+
|
|
336
342
|
CQRS modules additionally contain:
|
|
337
343
|
|
|
338
344
|
~~~text
|
|
@@ -612,11 +618,11 @@ my-api/
|
|
|
612
618
|
│ │ ├── config/ # environment configuration
|
|
613
619
|
│ │ ├── apperror/ # consistent application errors
|
|
614
620
|
│ │ ├── dberr/ # database error classification
|
|
615
|
-
│ │ ├── dbq/ # escaped contains-search
|
|
621
|
+
│ │ ├── dbq/ # escaped contains-search and whitelisted sort
|
|
616
622
|
│ │ ├── httpx/ # HTTP parsing and binding helpers
|
|
617
623
|
│ │ ├── id/ # UUID generation
|
|
618
624
|
│ │ ├── middleware/ # request ID, logging, errors, CORS
|
|
619
|
-
│ │ ├── pagination/ #
|
|
625
|
+
│ │ ├── pagination/ # ?limit/offset/q/sort/order parsing, responses
|
|
620
626
|
│ │ └── tx/ # transaction context helpers
|
|
621
627
|
│ └── app/ # empty until generate module is used
|
|
622
628
|
├── migrations/ # embedded, versioned SQL migrations
|
package/dist/commands/worker.js
CHANGED
|
@@ -130,8 +130,11 @@ function patchMakefile(makefilePath, opts) {
|
|
|
130
130
|
// Without it, .env.example's own `APP_ENV=development # prod: production`
|
|
131
131
|
// reaches `export` as a bare `#` and prints an error on every run.
|
|
132
132
|
const loadEnv = "@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a;";
|
|
133
|
-
const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both
|
|
134
|
-
"
|
|
133
|
+
const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both.\n" +
|
|
134
|
+
"#\n" +
|
|
135
|
+
"# migrate-up first, for the same reason `run` does it: cmd/api refuses to\n" +
|
|
136
|
+
"# boot against a database behind the migrations it embeds.\n" +
|
|
137
|
+
"dev: migrate-up\n" +
|
|
135
138
|
`\t${loadEnv} \\\n` +
|
|
136
139
|
"\t(trap 'kill 0' SIGINT SIGTERM; \\\n" +
|
|
137
140
|
"\t go run ./cmd/api & \\\n" +
|
|
@@ -113,7 +113,11 @@ function patchMainGoForAuth(mainGoPath, w) {
|
|
|
113
113
|
const importLine = `"${goModule}/internal/app/user"`;
|
|
114
114
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
115
115
|
const modelImportLine = `usermodel "${goModule}/internal/app/user/adapters/outbound/postgres"`;
|
|
116
|
-
|
|
116
|
+
// Only the development AutoMigrate list ever used this alias, so it is only
|
|
117
|
+
// an import where that list still exists — see the guard further down.
|
|
118
|
+
if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
|
|
119
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
120
|
+
}
|
|
117
121
|
if (w.worker) {
|
|
118
122
|
const queueImportLine = `"${goModule}/internal/platform/queue"`;
|
|
119
123
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
|
|
@@ -166,7 +170,13 @@ function patchMainGoForAuth(mainGoPath, w) {
|
|
|
166
170
|
'\treturn fmt.Errorf("create schema user_svc: %w", err)',
|
|
167
171
|
"}",
|
|
168
172
|
].join("\n");
|
|
169
|
-
|
|
173
|
+
// The development AutoMigrate bootstrap is gone from the template, and with
|
|
174
|
+
// it the schema/model markers. A project scaffolded before that still has
|
|
175
|
+
// them, and still wants its tables registered there — so these stay, guarded
|
|
176
|
+
// by the marker's presence rather than deleted. New projects skip them.
|
|
177
|
+
if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
|
|
178
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
|
|
179
|
+
}
|
|
170
180
|
const migrateLines = [
|
|
171
181
|
"&usermodel.User{},",
|
|
172
182
|
"&usermodel.UserEmail{},",
|
|
@@ -178,8 +188,10 @@ function patchMainGoForAuth(mainGoPath, w) {
|
|
|
178
188
|
// live in Redis, so reset/verification can share the user transaction.
|
|
179
189
|
migrateLines.push("&usermodel.AuthToken{},");
|
|
180
190
|
migrateLines.push("&usermodel.MFAEnrollment{},", "&usermodel.MFAChallenge{},", "&usermodel.MFARecoveryCode{},");
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
|
|
192
|
+
for (const line of migrateLines) {
|
|
193
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
|
|
194
|
+
}
|
|
183
195
|
}
|
|
184
196
|
// Keep auth construction inside the feature package. The root only chooses
|
|
185
197
|
// shared infrastructure and registers the resulting handler.
|
|
@@ -140,7 +140,13 @@ function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper, a
|
|
|
140
140
|
body: [
|
|
141
141
|
`func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
|
|
142
142
|
`\tp := pagination.Parse(c)`,
|
|
143
|
-
`\tfilter := ports.ListFilter{
|
|
143
|
+
`\tfilter := ports.ListFilter{`,
|
|
144
|
+
`\t\tSearch: p.Search,`,
|
|
145
|
+
`\t\tSort: p.Sort,`,
|
|
146
|
+
`\t\tDesc: p.Desc,`,
|
|
147
|
+
`\t\tLimit: p.Limit,`,
|
|
148
|
+
`\t\tOffset: p.Offset,`,
|
|
149
|
+
`\t}`,
|
|
144
150
|
`\titems, total, err := ${receiver}.${method.pascalName}(c.Request.Context(), filter)`,
|
|
145
151
|
`\tif err != nil {`,
|
|
146
152
|
`\t\tc.Error(${errorMapper}(err))`,
|
|
@@ -39,10 +39,11 @@ function mainGoLines(patch) {
|
|
|
39
39
|
return {
|
|
40
40
|
importLine: `"${patch.goModule}/internal/app/${patch.modulePath}"`,
|
|
41
41
|
modelImportLine: `${modelAlias} "${patch.goModule}/internal/app/${patch.modulePath}/adapters/outbound/postgres"`,
|
|
42
|
-
//
|
|
43
|
-
//
|
|
42
|
+
// Legacy only: projects generated before the development AutoMigrate
|
|
43
|
+
// bootstrap was removed still have the schema marker, and their tables are
|
|
44
|
+
// created by GORM rather than by the migration. One Exec per schema,
|
|
44
45
|
// guarded by its own sentinel so two modules sharing a schema name only
|
|
45
|
-
// ever produce one line
|
|
46
|
+
// ever produce one line.
|
|
46
47
|
schemaLines: [
|
|
47
48
|
`if err := db.Exec("CREATE SCHEMA IF NOT EXISTS ${patch.schemaName}").Error; err != nil {`,
|
|
48
49
|
`\treturn fmt.Errorf("create schema ${patch.schemaName}: %w", err)`,
|
|
@@ -73,7 +74,9 @@ function assertMainGoPatchable(mainGoPath) {
|
|
|
73
74
|
throw new Error(`${mainGoPath} not found — this doesn't look like a go-scaffold project`);
|
|
74
75
|
}
|
|
75
76
|
const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
76
|
-
|
|
77
|
+
// Not SCHEMA_MARKER/MODEL_MARKER: those belong to the development
|
|
78
|
+
// AutoMigrate bootstrap, which newer projects do not have.
|
|
79
|
+
const missing = [IMPORT_MARKER, ROUTE_MARKER].filter((m) => !(0, marker_patch_1.hasMarker)(content, m));
|
|
77
80
|
if (missing.length) {
|
|
78
81
|
throw new Error(`cmd/api/wiring.go is missing the marker comment${missing.length > 1 ? "s" : ""} this command patches at:\n` +
|
|
79
82
|
missing.map((m) => ` ${m}`).join("\n") +
|
|
@@ -94,9 +97,17 @@ function patchMainGo(mainGoPath, patch) {
|
|
|
94
97
|
// folder was deleted (main.go still wired) is a no-op, not a dup that
|
|
95
98
|
// panics gin at startup.
|
|
96
99
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
// The development AutoMigrate bootstrap is gone from the template, and with
|
|
101
|
+
// it the schema/model markers. A project scaffolded before that still has
|
|
102
|
+
// them, and still wants its tables registered there — so these stay, guarded
|
|
103
|
+
// by the marker's presence rather than deleted. New projects skip them.
|
|
104
|
+
if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
|
|
105
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
106
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine, migrateLine);
|
|
107
|
+
}
|
|
108
|
+
if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
|
|
109
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaLines, schemaSentinel);
|
|
110
|
+
}
|
|
100
111
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
|
|
101
112
|
content = (0, marker_patch_1.removeLines)(content, [UNUSED_API_LINE]);
|
|
102
113
|
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
@@ -167,16 +167,27 @@ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
|
|
|
167
167
|
const importLine = `"${goModule}/internal/app/role"`;
|
|
168
168
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
169
169
|
const modelImportLine = `rolepostgres "${goModule}/internal/app/role/adapters/outbound/postgres"`;
|
|
170
|
-
|
|
170
|
+
// Only the development AutoMigrate list ever used this alias.
|
|
171
|
+
if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
|
|
172
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
173
|
+
}
|
|
171
174
|
const schemaBlock = [
|
|
172
175
|
'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
|
|
173
176
|
'\treturn fmt.Errorf("create schema role_svc: %w", err)',
|
|
174
177
|
"}",
|
|
175
178
|
].join("\n");
|
|
176
|
-
|
|
179
|
+
// The development AutoMigrate bootstrap is gone from the template, and with
|
|
180
|
+
// it the schema/model markers. A project scaffolded before that still has
|
|
181
|
+
// them, and still wants its tables registered there — so these stay, guarded
|
|
182
|
+
// by the marker's presence rather than deleted. New projects skip them.
|
|
183
|
+
if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
|
|
184
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
|
|
185
|
+
}
|
|
177
186
|
const migrateLines = ["&rolepostgres.Role{},", "&rolepostgres.Permission{},", "&rolepostgres.RolePermission{},"];
|
|
178
|
-
|
|
179
|
-
|
|
187
|
+
if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
|
|
188
|
+
for (const line of migrateLines) {
|
|
189
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
|
|
190
|
+
}
|
|
180
191
|
}
|
|
181
192
|
const wiring = { goModule, queueBackend: "river", store, worker };
|
|
182
193
|
const authRouteLine = (0, auth_patcher_1.authHandlerLineFor)(wiring);
|
package/package.json
CHANGED
|
@@ -157,7 +157,13 @@ func (h *Handler) adminListUsers(c *gin.Context) {
|
|
|
157
157
|
p := pagination.Parse(c)
|
|
158
158
|
// One struct all the way down, so a filter added later is a field on
|
|
159
159
|
// ports.ListFilter and not a new argument on the three signatures below.
|
|
160
|
-
filter := ports.ListFilter{
|
|
160
|
+
filter := ports.ListFilter{
|
|
161
|
+
Search: p.Search,
|
|
162
|
+
Sort: p.Sort,
|
|
163
|
+
Desc: p.Desc,
|
|
164
|
+
Limit: p.Limit,
|
|
165
|
+
Offset: p.Offset,
|
|
166
|
+
}
|
|
161
167
|
items, total, err := h.svc.List(c.Request.Context(), filter)
|
|
162
168
|
if err != nil {
|
|
163
169
|
c.Error(toHTTPError(err))
|
|
@@ -77,6 +77,25 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
|
|
77
77
|
}))
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// sortable is the whole of what ?sort= may say on the admin user list: dbq.Sort
|
|
81
|
+
// resolves the requested name against Columns, so nothing off the request is
|
|
82
|
+
// ever interpolated into the ORDER BY.
|
|
83
|
+
var sortable = dbq.Sort{
|
|
84
|
+
Columns: map[string]string{
|
|
85
|
+
"name": "name",
|
|
86
|
+
"role": "role",
|
|
87
|
+
"created_at": "created_at",
|
|
88
|
+
// The address lives one table over, and only one row per user is
|
|
89
|
+
// primary (idx_user_emails_primary), so the subquery answers exactly
|
|
90
|
+
// one value. Correlated, so it runs per matching row — fine for a
|
|
91
|
+
// staff table; make it a LEFT JOIN on that index if this ever pages
|
|
92
|
+
// thousands, and give the query its own SELECT list when you do.
|
|
93
|
+
"email": "(SELECT e.email FROM user_svc.user_emails e WHERE e.user_id = user_svc.users.id AND e.is_primary)",
|
|
94
|
+
},
|
|
95
|
+
Default: "created_at desc",
|
|
96
|
+
Tiebreak: "id",
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
// FindAll answers one page of the filter and how many accounts it matched
|
|
81
100
|
// altogether. Two queries for that, because a count over a LIMITed query would
|
|
82
101
|
// only ever count the page.
|
|
@@ -104,10 +123,12 @@ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]do
|
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
var rows []User
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
126
|
+
// Newest account first until a client asks for something else, and always
|
|
127
|
+
// with the tiebreaker sortable carries: without it two accounts created in
|
|
128
|
+
// the same instant can swap between page 1 and page 2, showing one twice
|
|
129
|
+
// and hiding the other.
|
|
130
|
+
order := sortable.OrderBy(filter.Sort, filter.Desc)
|
|
131
|
+
err := matching().Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error
|
|
111
132
|
if err != nil {
|
|
112
133
|
return nil, 0, persistenceError(err)
|
|
113
134
|
}
|
|
@@ -21,6 +21,11 @@ type ListFilter struct {
|
|
|
21
21
|
// Search is ?q= as shared/pagination parsed it, matched against the name
|
|
22
22
|
// and the primary email address.
|
|
23
23
|
Search string
|
|
24
|
+
// Sort is ?sort= and Desc is ?order=desc, both as shared/pagination parsed
|
|
25
|
+
// them. Which column names are accepted is the repository's to say — see
|
|
26
|
+
// the dbq.Sort beside its FindAll.
|
|
27
|
+
Sort string
|
|
28
|
+
Desc bool
|
|
24
29
|
Limit int
|
|
25
30
|
Offset int
|
|
26
31
|
}
|
|
@@ -17,8 +17,8 @@ CREATE TABLE user_svc.auth_tokens (
|
|
|
17
17
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
18
18
|
);
|
|
19
19
|
|
|
20
|
-
-- Named to match model.AuthToken's own `index:` tags exactly, so the
|
|
21
|
-
--
|
|
20
|
+
-- Named to match model.AuthToken's own `index:` tags exactly, so the Go model
|
|
21
|
+
-- and the schema that actually exists describe the same indexes.
|
|
22
22
|
CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
|
|
23
23
|
CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
|
|
24
24
|
CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
|
|
@@ -7,9 +7,14 @@ get:
|
|
|
7
7
|
- $ref: '../common/parameters.yaml#/Limit'
|
|
8
8
|
- $ref: '../common/parameters.yaml#/Offset'
|
|
9
9
|
- $ref: '../common/parameters.yaml#/Search'
|
|
10
|
+
- $ref: '../common/parameters.yaml#/Sort'
|
|
11
|
+
- $ref: '../common/parameters.yaml#/Order'
|
|
10
12
|
responses:
|
|
11
13
|
"200":
|
|
12
|
-
description:
|
|
14
|
+
description: >-
|
|
15
|
+
paginated list; `q` matches name and primary email, and `sort` takes
|
|
16
|
+
name, email, role or created_at — anything else is the default order,
|
|
17
|
+
newest account first
|
|
13
18
|
content:
|
|
14
19
|
application/json:
|
|
15
20
|
schema:
|
|
@@ -329,10 +329,13 @@ go-scaffold add auth --store postgres --browser-topology same-site --defaults --
|
|
|
329
329
|
|
|
330
330
|
- APP_ENV is the only environment gate and accepts development or production.
|
|
331
331
|
Do not reintroduce an AUTO_MIGRATE setting.
|
|
332
|
-
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
- The schema comes from migrations/ in every environment, development
|
|
333
|
+
included. Apply it with make migrate-up; the startup version check fails
|
|
334
|
+
fast for a behind or dirty database. Never bootstrap or mutate schema at API
|
|
335
|
+
startup, and do not add a db.AutoMigrate(...) convenience for development:
|
|
336
|
+
GORM builds tables from the Go structs, so it produces none of the CHECK
|
|
337
|
+
constraints, foreign keys or seeded rows the SQL carries, and it silently
|
|
338
|
+
adds columns to an already-migrated database.
|
|
336
339
|
- Use generate migration for schema changes outside a new module, write and
|
|
337
340
|
review both migration directions, and test them against a disposable DB.
|
|
338
341
|
- Keep SQL, Redis, queue, mail, and other external resources under the
|
|
@@ -64,6 +64,15 @@ jobs:
|
|
|
64
64
|
migrate -path migrations -database "$DB_DSN" up
|
|
65
65
|
fi
|
|
66
66
|
|
|
67
|
+
# up -> down -all -> up. The only check that catches a down.sql that has
|
|
68
|
+
# stopped reversing its up.sql, which is otherwise discovered during a
|
|
69
|
+
# rollback — the worst moment to find out. Runs against the test database
|
|
70
|
+
# and leaves it migrated up for the steps below.
|
|
71
|
+
- name: Verify migrations reverse cleanly
|
|
72
|
+
env:
|
|
73
|
+
TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
|
|
74
|
+
run: make migrate-verify
|
|
75
|
+
|
|
67
76
|
- name: Test (required PostgreSQL integration tests cannot skip)
|
|
68
77
|
env:
|
|
69
78
|
TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
|
|
@@ -160,14 +160,46 @@ endpoint is production-ready.
|
|
|
160
160
|
|
|
161
161
|
- `APP_ENV` is the single environment gate and accepts only `development` or
|
|
162
162
|
`production`. Do not add a parallel flag such as `AUTO_MIGRATE`.
|
|
163
|
-
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
- **The schema comes from `migrations/` in every environment, development
|
|
164
|
+
included.** `cmd/api/wiring.go` runs the migration-version check and nothing
|
|
165
|
+
else: apply versioned SQL with `make migrate-up`, and let the check fail
|
|
166
|
+
fast when the database is behind or dirty. Never bootstrap or mutate schema
|
|
167
|
+
at API startup, and do not reintroduce a `db.AutoMigrate(...)` convenience
|
|
168
|
+
for development — GORM builds tables from the Go structs, so it writes none
|
|
169
|
+
of the CHECK constraints, none of the foreign keys and none of the seeded
|
|
170
|
+
rows a migration carries, and it is additive over an already-migrated
|
|
171
|
+
database, so a column a model has and a migration does not shows up on one
|
|
172
|
+
developer's machine and nowhere else.
|
|
168
173
|
- Any schema change is a reviewed pair of versioned files in `migrations/`.
|
|
169
174
|
Use `go-scaffold generate migration <name>` for changes outside a new
|
|
170
175
|
module, write both directions, and test them against a disposable database.
|
|
176
|
+
Write the SQL first and the Go struct second: the SQL is what exists, the
|
|
177
|
+
struct is Go's view of it, and doing it the other way round feels finished
|
|
178
|
+
while the database still has nothing.
|
|
179
|
+
- **A destructive change takes two deploys, never one.** During a rolling
|
|
180
|
+
deploy the old binary and the new one serve traffic at the same time, so a
|
|
181
|
+
migration has to be readable by the version of the code still running.
|
|
182
|
+
`CheckMigrationVersion` is built for that order — a database ahead of the
|
|
183
|
+
binary is a warning, behind it is fatal — which only works if migrations go
|
|
184
|
+
out first and stay backward compatible.
|
|
185
|
+
|
|
186
|
+
| change | deploys |
|
|
187
|
+
|---|---|
|
|
188
|
+
| add a nullable column, add a table, add an index | 1 |
|
|
189
|
+
| add `NOT NULL` with a `DEFAULT` | 1 — but it rewrites the table, so check its size first |
|
|
190
|
+
| rename a column | **2** |
|
|
191
|
+
| drop a column or a table | **2** |
|
|
192
|
+
| change a column's type | **2** |
|
|
193
|
+
|
|
194
|
+
The two are *expand* then *contract*: add the new shape and have the code
|
|
195
|
+
write both and prefer the new one; deploy that everywhere; only then drop the
|
|
196
|
+
old shape. `DROP COLUMN` in one round pulls the floor out from under the pods
|
|
197
|
+
still serving requests.
|
|
198
|
+
- `make migrate-verify` (up → down -all → up) runs in CI and is the only thing
|
|
199
|
+
that catches a `down.sql` which has quietly stopped reversing its `up.sql`.
|
|
200
|
+
Without it that is found during a rollback, which is the worst moment to find
|
|
201
|
+
out. Never run it against a database whose data matters — `down -all` means
|
|
202
|
+
it.
|
|
171
203
|
- Keep `*sql.DB`, Redis, queue, mail, and other external resources owned by
|
|
172
204
|
their composition root. Close them on every exit path and preserve the
|
|
173
205
|
signal-driven graceful shutdown; do not call `os.Exit` from a goroutine or
|
|
@@ -39,7 +39,12 @@ ENV_FILE ?= .env
|
|
|
39
39
|
|
|
40
40
|
.PHONY: tools run build test fmt vet lint tidy db-create db-drop migrate-up migrate-up-test migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# migrate-up first, because cmd/api refuses to boot against a database that is
|
|
43
|
+
# behind the migrations it embeds. The schema is the migrations' in every
|
|
44
|
+
# environment — this is the step that would otherwise be a db.AutoMigrate
|
|
45
|
+
# bootstrap, except it applies the same SQL production gets, constraints and
|
|
46
|
+
# seeded rows and all.
|
|
47
|
+
run: migrate-up
|
|
43
48
|
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/api
|
|
44
49
|
|
|
45
50
|
build:
|
|
@@ -115,26 +120,41 @@ db-drop:
|
|
|
115
120
|
exit 1; \
|
|
116
121
|
fi
|
|
117
122
|
|
|
123
|
+
# A project has no migrations until its first `generate module` or `add auth`,
|
|
124
|
+
# and `migrate up` treats an empty directory as an error rather than as nothing
|
|
125
|
+
# to do. `run` depends on this target, so without the guard the very first
|
|
126
|
+
# `make run` of a fresh scaffold fails on a database that is already correct.
|
|
118
127
|
migrate-up:
|
|
119
|
-
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a;
|
|
128
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
|
|
129
|
+
if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
|
|
130
|
+
migrate -path migrations -database "$$DB_DSN" up
|
|
120
131
|
|
|
121
132
|
# same migration files, applied to TEST_DB_DSN instead of DB_DSN — run this
|
|
122
133
|
# once after `make db-create DB_NAME=..._test` so repository_test.go files
|
|
123
134
|
# see real tables instead of skipping (or failing, if REQUIRE_TEST_DB=true).
|
|
124
135
|
migrate-up-test:
|
|
125
|
-
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a;
|
|
136
|
+
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
|
|
137
|
+
if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
|
|
138
|
+
migrate -path migrations -database "$$TEST_DB_DSN" up
|
|
126
139
|
|
|
127
140
|
migrate-down:
|
|
128
141
|
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" down 1
|
|
129
142
|
|
|
130
|
-
# runs up -> down-to-zero -> up
|
|
131
|
-
#
|
|
132
|
-
#
|
|
143
|
+
# runs up -> down-to-zero -> up to catch a bit-rotted down.sql (one that no
|
|
144
|
+
# longer reverses cleanly) before you actually need a rollback.
|
|
145
|
+
#
|
|
146
|
+
# TEST_DB_DSN, not DB_DSN: this drops every table, and it used to say "point
|
|
147
|
+
# DB_DSN at a throwaway database first" — advice its own recipe made impossible
|
|
148
|
+
# to follow, because the .env sourced on the line below overwrites whatever
|
|
149
|
+
# DB_DSN the caller set on the way in. The test database is the one that is
|
|
150
|
+
# meant to be destroyed, so it is the one named here.
|
|
133
151
|
migrate-verify:
|
|
134
152
|
@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
migrate -path migrations -database "$$
|
|
153
|
+
if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
|
|
154
|
+
: "$${TEST_DB_DSN:?TEST_DB_DSN is required — migrate-verify drops every table and will not guess a database}"; \
|
|
155
|
+
migrate -path migrations -database "$$TEST_DB_DSN" up && \
|
|
156
|
+
migrate -path migrations -database "$$TEST_DB_DSN" down -all && \
|
|
157
|
+
migrate -path migrations -database "$$TEST_DB_DSN" up
|
|
138
158
|
{{#if openapiDocs}}
|
|
139
159
|
|
|
140
160
|
# docs/openapi.yaml is hand-written and split across sibling files via relative
|
|
@@ -83,7 +83,7 @@ go mod tidy
|
|
|
83
83
|
{{#if worker}}{{#if (eq queue "river")}}make river-migrate # create River's job tables before queued mail
|
|
84
84
|
{{else}}# start Redis and set REDIS_URL before running the worker
|
|
85
85
|
{{/if}}{{/if}}
|
|
86
|
-
make run #
|
|
86
|
+
make run # needs `make migrate-up` first — the schema is the migrations'
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
`make run`/`make test`/`make migrate-up`/`make migrate-down` load `.env` if
|
|
@@ -154,9 +154,9 @@ migrate -path migrations -database "$DB_DSN" down 1
|
|
|
154
154
|
migrate create -ext sql -dir migrations -seq=false add_something # same as `go-scaffold generate migration`, if you'd rather not use the CLI
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
**Every environment:** the schema comes from `migrations/`. Nothing
|
|
158
|
+
bootstraps or mutates it at startup; run `migrate up` as its own step —
|
|
159
|
+
versioned and rollback-capable — and the app refuses to boot until you have.
|
|
160
160
|
|
|
161
161
|
In production, the app checks the DB's applied migration version
|
|
162
162
|
against the migration files baked into the binary (embedded at build time)
|
|
@@ -306,7 +306,7 @@ Before the first deploy of a release:
|
|
|
306
306
|
|
|
307
307
|
| | |
|
|
308
308
|
|---|---|
|
|
309
|
-
| `APP_ENV` | `production` | production
|
|
309
|
+
| `APP_ENV` | `production` | production hides internal error details; the applied migration version is required in every environment |
|
|
310
310
|
| migrations | `migrate -path migrations -database "$DB_DSN" up` as its own step, before the new binaries roll |
|
|
311
311
|
| `TRUSTED_PROXIES` | the CIDRs of your ingress, or the auth rate limiter keys on a header anyone can send |
|
|
312
312
|
| `COOKIE_SECURE` | `true` · `COOKIE_SAMESITE=none` as well if your frontend is on a different site |
|
|
@@ -54,19 +54,21 @@ func run() error {
|
|
|
54
54
|
}()
|
|
55
55
|
// go-scaffold:platform-init
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
// The schema comes from migrations/ in every environment, development
|
|
58
|
+
// included: `make migrate-up` applies it, and this refuses to boot when the
|
|
59
|
+
// database is behind or dirty.
|
|
60
|
+
//
|
|
61
|
+
// There is deliberately no db.AutoMigrate bootstrap for development. GORM
|
|
62
|
+
// builds tables from the Go structs, so it writes none of the CHECK
|
|
63
|
+
// constraints, none of the foreign keys and none of the seeded rows a
|
|
64
|
+
// migration carries — a schema that only looks like production's, on which
|
|
65
|
+
// a seeded catalogue is simply empty. It is also additive over an
|
|
66
|
+
// already-migrated database, so a column a model has and a migration does
|
|
67
|
+
// not appears on the developer's machine and nowhere else.
|
|
68
|
+
//
|
|
69
|
+
// Every migration creates the schema its tables live in, so nothing here
|
|
70
|
+
// needs to.
|
|
71
|
+
if err := database.CheckMigrationVersion(db); err != nil {
|
|
70
72
|
return fmt.Errorf("migration version check: %w", err)
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -76,6 +76,14 @@ func CheckMigrationVersion(db *gorm.DB) error {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// A scaffold that has not generated a module or added auth yet embeds no
|
|
80
|
+
// migrations, so there is no schema_migrations table to read and nothing
|
|
81
|
+
// to compare against. That is a project with no schema, not a project
|
|
82
|
+
// whose schema is behind.
|
|
83
|
+
if latest == 0 {
|
|
84
|
+
return nil
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
var version int
|
|
80
88
|
var dirty bool
|
|
81
89
|
row := db.Raw("SELECT version, dirty FROM schema_migrations").Row()
|
|
@@ -45,3 +45,51 @@ func Search(db *gorm.DB, term string, cols ...string) *gorm.DB {
|
|
|
45
45
|
// narrowing it.
|
|
46
46
|
return db.Where("("+strings.Join(conds, " OR ")+")", args...)
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// Sort is a list endpoint's ordering contract: the columns it accepts, the
|
|
50
|
+
// order it has before anyone asks for one, and the column that breaks ties.
|
|
51
|
+
// Declare it once per repository, beside the FindAll that uses it.
|
|
52
|
+
type Sort struct {
|
|
53
|
+
// Columns maps the names the API accepts to the SQL each one means, and is
|
|
54
|
+
// the whole of what can reach the clause: ORDER BY takes no bound
|
|
55
|
+
// parameter, so a ?sort= that is not a key here is never interpolated. A
|
|
56
|
+
// value the table does not hold — an address one table over — is a
|
|
57
|
+
// subquery rather than a join, which would need its own SELECT list to
|
|
58
|
+
// keep Find scanning the right row.
|
|
59
|
+
Columns map[string]string
|
|
60
|
+
// Default is the order for a request that asked for nothing Columns knows,
|
|
61
|
+
// written without the tiebreaker: OrderBy adds it.
|
|
62
|
+
Default string
|
|
63
|
+
// Tiebreak is a column unique enough to page by, usually the primary key.
|
|
64
|
+
// Without it two rows equal on the sorted column can swap places between
|
|
65
|
+
// page 1 and page 2, showing one twice and hiding the other. Leave it empty
|
|
66
|
+
// only when Columns and Default are unique on their own.
|
|
67
|
+
Tiebreak string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// OrderBy resolves what the request asked for into an ORDER BY clause:
|
|
71
|
+
//
|
|
72
|
+
// q = q.Order(thingSort.OrderBy(filter.Sort, filter.Desc))
|
|
73
|
+
//
|
|
74
|
+
// Sorted columns get NULLS LAST in both directions, because a row with no
|
|
75
|
+
// value is missing an answer rather than holding the largest one — the account
|
|
76
|
+
// that has never signed in belongs at the bottom whichever way the column is
|
|
77
|
+
// pointed. It is a no-op on a column that cannot be null.
|
|
78
|
+
func (s Sort) OrderBy(sort string, desc bool) string {
|
|
79
|
+
clause := s.Default
|
|
80
|
+
if col, ok := s.Columns[sort]; ok {
|
|
81
|
+
dir := "asc"
|
|
82
|
+
if desc {
|
|
83
|
+
dir = "desc"
|
|
84
|
+
}
|
|
85
|
+
clause = col + " " + dir + " NULLS LAST"
|
|
86
|
+
}
|
|
87
|
+
switch {
|
|
88
|
+
case s.Tiebreak == "":
|
|
89
|
+
return clause
|
|
90
|
+
case clause == "":
|
|
91
|
+
return s.Tiebreak
|
|
92
|
+
default:
|
|
93
|
+
return clause + ", " + s.Tiebreak
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -64,3 +64,37 @@ func TestSearchWithoutATermIsANoOp(t *testing.T) {
|
|
|
64
64
|
t.Errorf("empty term still filtered: %s", stmt.SQL.String())
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
// The whitelist is the security boundary: ?sort= comes off the request, and
|
|
69
|
+
// ORDER BY takes no bound parameter. The rest is the two rules every list gets
|
|
70
|
+
// for free — a tiebreaker, and empty values last.
|
|
71
|
+
func TestSortOrderBy(t *testing.T) {
|
|
72
|
+
sorter := Sort{
|
|
73
|
+
Columns: map[string]string{"name": "name", "seen_at": "seen_at"},
|
|
74
|
+
Default: "created_at desc",
|
|
75
|
+
Tiebreak: "id",
|
|
76
|
+
}
|
|
77
|
+
for _, tc := range []struct {
|
|
78
|
+
sort string
|
|
79
|
+
desc bool
|
|
80
|
+
want string
|
|
81
|
+
}{
|
|
82
|
+
{"name", false, "name asc NULLS LAST, id"},
|
|
83
|
+
{"seen_at", true, "seen_at desc NULLS LAST, id"},
|
|
84
|
+
// Nobody asked for an order.
|
|
85
|
+
{"", false, "created_at desc, id"},
|
|
86
|
+
// A name that is not on the list is the default order, not SQL.
|
|
87
|
+
{"name; DROP TABLE things --", true, "created_at desc, id"},
|
|
88
|
+
} {
|
|
89
|
+
if got := sorter.OrderBy(tc.sort, tc.desc); got != tc.want {
|
|
90
|
+
t.Errorf("OrderBy(%q, %v) = %q, want %q", tc.sort, tc.desc, got, tc.want)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A table keyed by something unique on its own needs no tiebreaker, and
|
|
95
|
+
// must not be given one it has no column for.
|
|
96
|
+
byCode := Sort{Columns: map[string]string{"name": "name"}, Tiebreak: "code"}
|
|
97
|
+
if got := byCode.OrderBy("", false); got != "code" {
|
|
98
|
+
t.Errorf("no default and no sort should be the tiebreaker alone, got %q", got)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -24,10 +24,17 @@ type Params struct {
|
|
|
24
24
|
// Search is ?q= trimmed, empty when the caller sent none — so a repository
|
|
25
25
|
// can pass it straight to dbq.Search, which no-ops on an empty term.
|
|
26
26
|
Search string
|
|
27
|
+
// Sort is ?sort=, the column the client asked to be ordered by, and Desc
|
|
28
|
+
// is ?order=desc. Nothing is validated here on purpose: which names an
|
|
29
|
+
// endpoint accepts is its repository's to say, and dbq.Sort is where that
|
|
30
|
+
// whitelist is applied — a name it does not know falls back to the list's
|
|
31
|
+
// own order rather than failing the request.
|
|
32
|
+
Sort string
|
|
33
|
+
Desc bool
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
// Parse reads ?limit=&offset=&q= off the request so every feature
|
|
30
|
-
// same way.
|
|
36
|
+
// Parse reads ?limit=&offset=&q=&sort=&order= off the request so every feature
|
|
37
|
+
// parses the same way.
|
|
31
38
|
func Parse(c *gin.Context) Params {
|
|
32
39
|
p := Params{Limit: defaultLimit}
|
|
33
40
|
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
|
|
@@ -47,6 +54,10 @@ func Parse(c *gin.Context) Params {
|
|
|
47
54
|
}
|
|
48
55
|
p.Search = q
|
|
49
56
|
}
|
|
57
|
+
p.Sort = strings.TrimSpace(c.Query("sort"))
|
|
58
|
+
// Ascending unless asked otherwise: one spelling of "the other way", and
|
|
59
|
+
// anything else read as the default rather than refused.
|
|
60
|
+
p.Desc = c.Query("order") == "desc"
|
|
50
61
|
return p
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -143,9 +143,12 @@ wrong. See `patterns.md`, "Filtered Lists".
|
|
|
143
143
|
|
|
144
144
|
**Decision:** PostgreSQL + GORM, schema managed by
|
|
145
145
|
[golang-migrate](https://github.com/golang-migrate/migrate).
|
|
146
|
-
**Rationale:**
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
**Rationale:** one source of schema in every environment — the separate,
|
|
147
|
+
versioned, rollback-capable SQL migrations, applied by `migrate up` before the
|
|
148
|
+
binary starts. A development-only `db.AutoMigrate` convenience would build
|
|
149
|
+
tables from the Go structs instead, dropping the CHECK constraints, foreign
|
|
150
|
+
keys and seeded rows the SQL carries, and adding columns to an
|
|
151
|
+
already-migrated database where no test would see the difference.
|
|
149
152
|
|
|
150
153
|
{{#if docker}}- `docker-compose.yml` provides the local Postgres instance
|
|
151
154
|
{{/if}}
|
|
@@ -16,3 +16,13 @@ Search:
|
|
|
16
16
|
in: query
|
|
17
17
|
description: "free-text search; which columns it matches is documented per endpoint"
|
|
18
18
|
schema: { type: string, maxLength: 100 }
|
|
19
|
+
Sort:
|
|
20
|
+
name: sort
|
|
21
|
+
in: query
|
|
22
|
+
description: "column to order by; which columns are accepted is documented per endpoint, and anything else is read as that list's own default order"
|
|
23
|
+
schema: { type: string }
|
|
24
|
+
Order:
|
|
25
|
+
name: order
|
|
26
|
+
in: query
|
|
27
|
+
description: "direction for `sort`; ascending unless this is `desc`. Rows with no value sort last either way"
|
|
28
|
+
schema: { type: string, enum: [asc, desc], default: asc }
|
|
@@ -50,8 +50,8 @@ must import the specific boundary package it belongs to.
|
|
|
50
50
|
kept in `domain/`.
|
|
51
51
|
- `TableName()` returns a schema-qualified name (`order_svc.orders`, not
|
|
52
52
|
`orders`) — every domain gets its own Postgres schema, created by its own
|
|
53
|
-
migration (`CREATE SCHEMA IF NOT EXISTS`)
|
|
54
|
-
|
|
53
|
+
migration (`CREATE SCHEMA IF NOT EXISTS`), which is the only thing that
|
|
54
|
+
creates it. A cross-domain FK is still fine — see
|
|
55
55
|
the FK rules below — this only stops one domain's table from silently
|
|
56
56
|
colliding with another's.
|
|
57
57
|
|
|
@@ -159,8 +159,8 @@ if this project has one):
|
|
|
159
159
|
associations / belongs-to — that's what keeps one domain package from
|
|
160
160
|
importing another.
|
|
161
161
|
2. **Declare the FK constraint in migration SQL**
|
|
162
|
-
(`REFERENCES ... ON DELETE ...`), not a GORM tag —
|
|
163
|
-
|
|
162
|
+
(`REFERENCES ... ON DELETE ...`), not a GORM tag — a GORM tag describes the
|
|
163
|
+
constraint to Go and to nothing that runs, so the database would not have it.
|
|
164
164
|
3. **Map the FK error to the right status** in the outbound adapter with
|
|
165
165
|
`dberr.IsForeignKey`, then let the inbound adapter render it — inserting a
|
|
166
166
|
reference to a missing parent, or deleting a parent that still has children,
|
|
@@ -255,9 +255,10 @@ business logic — same spirit as `generate module`'s placeholder fields.
|
|
|
255
255
|
A `crud`-surface module is generated with this already wired; a module that
|
|
256
256
|
grows a list later copies the same four steps. Nothing about it is magic.
|
|
257
257
|
|
|
258
|
-
**1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
**1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset`,
|
|
259
|
+
`Search` (`?q=`, trimmed and capped at 100 runes) and the requested order —
|
|
260
|
+
`Sort` (`?sort=`) and `Desc` (`?order=desc`). Read the filters this endpoint
|
|
261
|
+
owns off the query string beside them. An unreadable filter value means
|
|
261
262
|
*no* filter, not `400` — a list narrows on a best effort.
|
|
262
263
|
|
|
263
264
|
**2. One filter struct, in `ports/`.** `ports.ListFilter` travels whole through
|
|
@@ -270,6 +271,8 @@ everything".
|
|
|
270
271
|
```go
|
|
271
272
|
type ListFilter struct {
|
|
272
273
|
Search string
|
|
274
|
+
Sort string
|
|
275
|
+
Desc bool
|
|
273
276
|
Limit int
|
|
274
277
|
Offset int
|
|
275
278
|
// Status, OwnerID, DateFrom … whatever this module actually filters by.
|
|
@@ -288,21 +291,47 @@ accumulate on a `*gorm.DB` value, so a count reusing the page's query object
|
|
|
288
291
|
silently inherits its `LIMIT`. Use `dbq.Search(q, term, cols...)` for a
|
|
289
292
|
contains-search over columns on the same table, and `dbq.LikePattern(term)`
|
|
290
293
|
when the search spans a subquery or a join. Both escape `%` and `_`, so
|
|
291
|
-
someone typing `50%` searches for `50%` instead of matching every row.
|
|
292
|
-
keep a tiebreaker in the sort (`ORDER BY created_at DESC, id`) or two rows
|
|
293
|
-
written in the same instant can swap between page 1 and page 2 — showing one
|
|
294
|
-
twice and hiding the other.
|
|
294
|
+
someone typing `50%` searches for `50%` instead of matching every row.
|
|
295
295
|
|
|
296
|
-
The
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
The order is a `dbq.Sort` declared beside `FindAll`, not an `Order` string
|
|
297
|
+
written per query:
|
|
298
|
+
|
|
299
|
+
```go
|
|
300
|
+
var sortable = dbq.Sort{
|
|
301
|
+
Columns: map[string]string{"name": "name", "created_at": "created_at"},
|
|
302
|
+
Default: "created_at desc",
|
|
303
|
+
Tiebreak: "id",
|
|
304
|
+
}
|
|
305
|
+
...
|
|
306
|
+
q = q.Order(sortable.OrderBy(filter.Sort, filter.Desc))
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`Columns` maps the names the API accepts to the SQL each one means and is the
|
|
310
|
+
whole of what can reach the clause — `ORDER BY` takes no bound parameter, so a
|
|
311
|
+
`?sort=` that is not one of its keys falls back to `Default` rather than being
|
|
312
|
+
interpolated. `OrderBy` adds what every list needs and nobody should have to
|
|
313
|
+
remember: `Tiebreak` on the end (without it two rows equal on the sorted column
|
|
314
|
+
swap between page 1 and page 2, showing one twice and hiding the other) and
|
|
315
|
+
`NULLS LAST` in both directions (a row with no value is missing an answer, not
|
|
316
|
+
holding the largest one). A table already unique on its order — roles, keyed by
|
|
317
|
+
`code` — sets `Tiebreak` to that column and leaves `Default` empty.
|
|
318
|
+
|
|
319
|
+
A column the client sees but this table does not hold, such as a primary email
|
|
320
|
+
one table over, belongs in `Columns` as a subquery rather than a join: a join
|
|
321
|
+
would need its own `SELECT` list to keep `Find` scanning the right row.
|
|
322
|
+
|
|
323
|
+
The generated `FindAll` calls `dbq.Search` with no columns and carries an empty
|
|
324
|
+
`Columns` map, both no-ops: `?q=` and `?sort=` are accepted and ignored until
|
|
325
|
+
you name the columns this list is searched and ordered by. Those two TODOs are
|
|
326
|
+
the lines standing between the scaffold and a working list.
|
|
300
327
|
|
|
301
328
|
**4. Respond with the shared envelope.** `p.ResponseWithTotal(out, total)` —
|
|
302
329
|
`{ data, limit, offset, total }`, the same shape for every resource. Anything
|
|
303
330
|
extra is a named key added to that map and documented in the endpoint's
|
|
304
|
-
OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit
|
|
305
|
-
for the query parameters.
|
|
331
|
+
OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit`, `#/Offset`,
|
|
332
|
+
`#/Sort` and `#/Order` for the query parameters. `#/Sort` carries no enum —
|
|
333
|
+
which columns an endpoint accepts is a sentence in its own response
|
|
334
|
+
description, the same way `q` says what it matches.
|
|
306
335
|
|
|
307
336
|
Counts that answer a *different* question from the page — "how many are open
|
|
308
337
|
and how many are closed, whichever tab is showing" — belong beside the
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
{{/if}}{{#if worker}}{{#if (eq queue "river")}}- Run `make river-migrate` before relying on queued jobs in a fresh database;
|
|
41
41
|
run `make river-migrate-test` as well when `REQUIRE_TEST_DB=true` runs the
|
|
42
42
|
River worker round-trip test.{{/if}}{{/if}}
|
|
43
|
-
-
|
|
44
|
-
|
|
43
|
+
- The schema is the migrations', in every environment: `migrate up` applies
|
|
44
|
+
it and the startup version check refuses to boot without it. There is no
|
|
45
|
+
development table bootstrap
|
|
45
46
|
{{#if worker}}{{#if (eq queue "river")}}- River uses the database/sql driver and polls for jobs instead of receiving
|
|
46
47
|
PostgreSQL LISTEN/NOTIFY wakeups; this is deliberate for a shared GORM pool.
|
|
47
48
|
{{/if}}{{/if}}
|
|
@@ -22,9 +22,11 @@ get:
|
|
|
22
22
|
- $ref: '../common/parameters.yaml#/Limit'
|
|
23
23
|
- $ref: '../common/parameters.yaml#/Offset'
|
|
24
24
|
- $ref: '../common/parameters.yaml#/Search'
|
|
25
|
+
- $ref: '../common/parameters.yaml#/Sort'
|
|
26
|
+
- $ref: '../common/parameters.yaml#/Order'
|
|
25
27
|
responses:
|
|
26
28
|
"200":
|
|
27
|
-
description: "paginated list; narrow it with ?q= and whatever filters this module adds"
|
|
29
|
+
description: "paginated list; narrow it with ?q= and whatever filters this module adds, and order it with ?sort= once the repository's dbq.Sort names a column"
|
|
28
30
|
content:
|
|
29
31
|
application/json:
|
|
30
32
|
schema:
|
|
@@ -111,7 +111,13 @@ func (h *Handler) list(c *gin.Context) {
|
|
|
111
111
|
// One struct all the way down, so this module's own filters are read off
|
|
112
112
|
// the query string here and added as fields on ports.ListFilter — never as
|
|
113
113
|
// extra arguments on the three signatures below.
|
|
114
|
-
filter := ports.ListFilter{
|
|
114
|
+
filter := ports.ListFilter{
|
|
115
|
+
Search: p.Search,
|
|
116
|
+
Sort: p.Sort,
|
|
117
|
+
Desc: p.Desc,
|
|
118
|
+
Limit: p.Limit,
|
|
119
|
+
Offset: p.Offset,
|
|
120
|
+
}
|
|
115
121
|
{{#if cqrs}}
|
|
116
122
|
items, total, err := h.queries.List(c.Request.Context(), filter)
|
|
117
123
|
{{else}}
|
|
@@ -31,6 +31,20 @@ func (r *Repository) Create(ctx context.Context, m *domain.{{pascalName}}) error
|
|
|
31
31
|
return nil
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// sortable is the whole of what ?sort= may say for this list: dbq.Sort
|
|
35
|
+
// resolves the requested name against Columns, so nothing off the request is
|
|
36
|
+
// ever interpolated into the ORDER BY.
|
|
37
|
+
//
|
|
38
|
+
// TODO: name the columns this list may be ordered by, mapping the name the API
|
|
39
|
+
// accepts to the SQL it means — {"name": "name", "created_at": "created_at"}.
|
|
40
|
+
// Until then ?sort= is accepted and ignored, the same way dbq.Search is until
|
|
41
|
+
// it has columns.
|
|
42
|
+
var sortable = dbq.Sort{
|
|
43
|
+
Columns: map[string]string{},
|
|
44
|
+
Default: "created_at desc",
|
|
45
|
+
Tiebreak: "id",
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
// FindAll answers one page of the filter and how many rows it matched
|
|
35
49
|
// altogether. Two queries, because a count over a LIMITed query would only
|
|
36
50
|
// ever count the page.
|
|
@@ -52,10 +66,12 @@ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]do
|
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
var rows []{{pascalName}}Model
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// showing one twice and hiding
|
|
58
|
-
|
|
69
|
+
// Newest first until a client asks for something else, and always with the
|
|
70
|
+
// tiebreaker sortable carries: without it two rows written in the same
|
|
71
|
+
// instant can swap between page 1 and page 2, showing one twice and hiding
|
|
72
|
+
// the other.
|
|
73
|
+
order := sortable.OrderBy(filter.Sort, filter.Desc)
|
|
74
|
+
if err := matching().Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error; err != nil {
|
|
59
75
|
return nil, 0, mapDatabaseError(err)
|
|
60
76
|
}
|
|
61
77
|
items := make([]domain.{{pascalName}}, len(rows))
|
|
@@ -21,6 +21,11 @@ type ListFilter struct {
|
|
|
21
21
|
// Search is ?q= as shared/pagination parsed it, empty when the caller
|
|
22
22
|
// sent none.
|
|
23
23
|
Search string
|
|
24
|
+
// Sort is ?sort= and Desc is ?order=desc, both as shared/pagination parsed
|
|
25
|
+
// them. Which column names are accepted is the repository's to say — see
|
|
26
|
+
// the dbq.Sort beside its FindAll.
|
|
27
|
+
Sort string
|
|
28
|
+
Desc bool
|
|
24
29
|
Limit int
|
|
25
30
|
Offset int
|
|
26
31
|
}
|