@nakedev/go-scaffold 0.5.5 → 0.8.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.
Files changed (48) hide show
  1. package/README.md +16 -2
  2. package/dist/commands/auth.js +6 -1
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/method.js +27 -1
  5. package/dist/index.js +20 -6
  6. package/dist/prompts/auth-wizard.js +33 -1
  7. package/dist/templates/create-manifest.js +8 -0
  8. package/dist/utils/hexagonal-method-patcher.js +79 -20
  9. package/package.json +1 -1
  10. package/templates/add/auth/docs/login.yaml.hbs +9 -1
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
  12. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
  15. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
  16. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
  17. package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
  18. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
  19. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
  20. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
  21. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
  22. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  23. package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
  24. package/templates/add/rbac/docs/users.yaml.hbs +2 -1
  25. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +6 -2
  26. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
  27. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +9 -1
  28. package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
  29. package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
  30. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
  31. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
  32. package/templates/create/base/AGENTS.md.hbs +9 -1
  33. package/templates/create/base/README.md.hbs +2 -1
  34. package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
  35. package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
  36. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
  37. package/templates/create/features/docs/architecture.md.hbs +9 -6
  38. package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
  39. package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
  40. package/templates/create/features/docs/patterns.md.hbs +60 -0
  41. package/templates/generate/module/docs/collection.yaml.hbs +2 -1
  42. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
  43. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
  44. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
  45. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
  46. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
  47. package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
  48. package/templates/generate/module/hexagonal/ports/repository.go.hbs +18 -2
@@ -10,11 +10,28 @@ import (
10
10
  "github.com/google/uuid"
11
11
  )
12
12
 
13
+ // ListFilter is the whole question the admin user list asks. The zero value is
14
+ // "the first page of everything", so a caller only sets what it narrows by.
15
+ //
16
+ // Put new filters here as fields rather than as arguments: one struct travels
17
+ // handler -> application -> repository, so adding a filter later is a field
18
+ // and not a fourth parameter on three signatures. See
19
+ // docs/architect/patterns.md.
20
+ type ListFilter struct {
21
+ // Search is ?q= as shared/pagination parsed it, matched against the name
22
+ // and the primary email address.
23
+ Search string
24
+ Limit int
25
+ Offset int
26
+ }
27
+
13
28
  type UserRepository interface {
14
29
  FindByEmail(context.Context, string) (*domain.User, error)
15
30
  FindByID(context.Context, uuid.UUID) (*domain.User, error)
16
31
  UpdateUser(context.Context, *domain.User) error
17
- FindAll(context.Context, int, int) ([]domain.User, error)
32
+ // FindAll answers one page and the total the filter matched, which is what
33
+ // a client needs to draw "page 3 of 12".
34
+ FindAll(context.Context, ListFilter) ([]domain.User, int64, error)
18
35
  FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
36
  ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
20
37
  FindIdentityByProviderUID(context.Context, string, string) (*domain.Identity, error)
@@ -23,8 +40,9 @@ type UserRepository interface {
23
40
  UpdateIdentity(context.Context, *domain.Identity) error
24
41
  DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
25
42
  LoginLockedUntil(context.Context, string) (time.Time, error)
26
- RecordLoginFailure(context.Context, string, int, time.Duration) error
27
- ClearLoginFailures(context.Context, string) error
43
+ {{#if fixedLockout}} RecordLoginFailure(context.Context, string, string, int, time.Duration, time.Duration) error
44
+ {{else}} RecordLoginFailure(context.Context, string, string, int, time.Duration) error
45
+ {{/if}} ClearLoginFailures(context.Context, string) error
28
46
  // go-scaffold:repository-interface
29
47
  }
30
48
 
@@ -2,9 +2,19 @@
2
2
  -- so it works for addresses that have no account (otherwise throttling itself
3
3
  -- leaks which addresses are registered) and so this table is not a user list.
4
4
  -- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
5
+ -- last_attempt fingerprints the most recent wrong password so replaying the
6
+ -- same one does not count again (see RecordLoginFailure). It is an HMAC, not a
7
+ -- plain hash: a leaked table must not become a dictionary of near-miss
8
+ -- passwords for known accounts.
5
9
  CREATE TABLE user_svc.login_throttle (
6
10
  email_hash TEXT PRIMARY KEY,
7
11
  failures INTEGER NOT NULL DEFAULT 0,
12
+ last_attempt TEXT,
8
13
  locked_until TIMESTAMPTZ,
9
14
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
15
  );
16
+
17
+ -- The table sweeps itself: every failed login deletes the rows nothing has
18
+ -- touched for a day (see RecordLoginFailure). This index is what keeps that
19
+ -- delete from scanning the table each time.
20
+ CREATE INDEX login_throttle_updated_at_idx ON user_svc.login_throttle (updated_at);
@@ -6,9 +6,10 @@ get:
6
6
  parameters:
7
7
  - $ref: '../common/parameters.yaml#/Limit'
8
8
  - $ref: '../common/parameters.yaml#/Offset'
9
+ - $ref: '../common/parameters.yaml#/Search'
9
10
  responses:
10
11
  "200":
11
- description: paginated list
12
+ description: "paginated list; `q` matches code and display name"
12
13
  content:
13
14
  application/json:
14
15
  schema:
@@ -6,9 +6,10 @@ get:
6
6
  parameters:
7
7
  - $ref: '../common/parameters.yaml#/Limit'
8
8
  - $ref: '../common/parameters.yaml#/Offset'
9
+ - $ref: '../common/parameters.yaml#/Search'
9
10
  responses:
10
11
  "200":
11
- description: paginated list
12
+ description: "paginated list; `q` matches name and primary email"
12
13
  content:
13
14
  application/json:
14
15
  schema:
@@ -9,6 +9,7 @@ import (
9
9
 
10
10
  "{{goModule}}/internal/app/role/application"
11
11
  "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
12
13
  "{{goModule}}/internal/shared/apperror"
13
14
  "{{goModule}}/internal/shared/httpx"
14
15
  "{{goModule}}/internal/shared/middleware"
@@ -40,7 +41,10 @@ func (h *Handler) Register(rg gin.IRouter) {
40
41
 
41
42
  func (h *Handler) list(c *gin.Context) {
42
43
  p := pagination.Parse(c)
43
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
44
+ // One struct all the way down, so a filter added later is a field on
45
+ // ports.ListFilter and not a new argument on the three signatures below.
46
+ filter := ports.ListFilter{Search: p.Search, Limit: p.Limit, Offset: p.Offset}
47
+ items, total, err := h.svc.List(c.Request.Context(), filter)
44
48
  if err != nil {
45
49
  c.Error(toHTTPError(err))
46
50
  return
@@ -49,7 +53,7 @@ func (h *Handler) list(c *gin.Context) {
49
53
  for i := range items {
50
54
  out[i] = toRoleResponse(application.ToRoleResponse(items[i]))
51
55
  }
52
- c.JSON(http.StatusOK, p.Response(out))
56
+ c.JSON(http.StatusOK, p.ResponseWithTotal(out, total))
53
57
  }
54
58
 
55
59
  func (h *Handler) create(c *gin.Context) {
@@ -6,6 +6,7 @@ import (
6
6
 
7
7
  "{{goModule}}/internal/app/role/domain"
8
8
  "{{goModule}}/internal/app/role/ports"
9
+ "{{goModule}}/internal/shared/dbq"
9
10
  "{{goModule}}/internal/shared/dberr"
10
11
  "{{goModule}}/internal/shared/tx"
11
12
 
@@ -27,17 +28,33 @@ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]st
27
28
  return codes, mapDatabaseError(err)
28
29
  }
29
30
 
30
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.Role, error) {
31
+ // FindAll answers one page of the filter and how many roles it matched
32
+ // altogether. Two queries, because a count over a LIMITed query would only
33
+ // ever count the page.
34
+ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.Role, int64, error) {
35
+ // A closure rather than one *gorm.DB reused twice: conditions accumulate
36
+ // on the value, so the count would silently inherit the page's LIMIT.
37
+ matching := func() *gorm.DB {
38
+ q := tx.From(ctx, r.db).WithContext(ctx).Model(&Role{})
39
+ return dbq.Search(q, filter.Search, "code", "name")
40
+ }
41
+
42
+ var total int64
43
+ if err := matching().Count(&total).Error; err != nil {
44
+ return nil, 0, mapDatabaseError(err)
45
+ }
46
+
31
47
  var rows []Role
32
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
48
+ // code is unique, so it needs no tiebreaker to page stably.
49
+ err := matching().Order("code").Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error
33
50
  if err != nil {
34
- return nil, mapDatabaseError(err)
51
+ return nil, 0, mapDatabaseError(err)
35
52
  }
36
53
  items := make([]domain.Role, len(rows))
37
54
  for i := range rows {
38
55
  items[i] = toDomainRole(&rows[i])
39
56
  }
40
- return items, nil
57
+ return items, total, nil
41
58
  }
42
59
 
43
60
  func (r *Repository) FindByCode(ctx context.Context, code string) (*domain.Role, error) {
@@ -36,9 +36,17 @@ type PermissionResponse struct {
36
36
  }
37
37
 
38
38
  func ToRoleResponse(item RoleListItem) RoleResponse {
39
+ // A role holding nothing must still answer `[]`, never `null`: Pluck
40
+ // leaves the slice nil, and the seeded `staff` role starts with no grants
41
+ // at all — so this is the common case, not an edge one. Every HTTP path
42
+ // (list, create, set-permissions) maps through here.
43
+ permissions := item.Permissions
44
+ if permissions == nil {
45
+ permissions = []string{}
46
+ }
39
47
  return RoleResponse{
40
48
  Code: item.Role.Code, Name: item.Role.Name, IsSystem: item.Role.IsSystem,
41
- Permissions: item.Permissions, CreatedAt: item.Role.CreatedAt,
49
+ Permissions: permissions, CreatedAt: item.Role.CreatedAt,
42
50
  }
43
51
  }
44
52
 
@@ -15,7 +15,7 @@ import (
15
15
  type ServicePort interface {
16
16
  PermissionsOf(context.Context, string) (map[string]struct{}, error)
17
17
  CodeExists(context.Context, string) (bool, error)
18
- List(context.Context, int, int) ([]RoleListItem, error)
18
+ List(context.Context, ports.ListFilter) ([]RoleListItem, int64, error)
19
19
  Create(context.Context, CreateInput) (*RoleListItem, error)
20
20
  SetPermissions(context.Context, string, []string) (*RoleListItem, error)
21
21
  Delete(context.Context, string) error
@@ -50,20 +50,20 @@ func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
50
50
  return true, nil
51
51
  }
52
52
 
53
- func (s *Service) List(ctx context.Context, limit, offset int) ([]RoleListItem, error) {
54
- roles, err := s.repo.FindAll(ctx, limit, offset)
53
+ func (s *Service) List(ctx context.Context, filter ports.ListFilter) ([]RoleListItem, int64, error) {
54
+ roles, total, err := s.repo.FindAll(ctx, filter)
55
55
  if err != nil {
56
- return nil, err
56
+ return nil, 0, err
57
57
  }
58
58
  out := make([]RoleListItem, len(roles))
59
59
  for i, role := range roles {
60
60
  perms, err := s.repo.PermissionCodes(ctx, role.Code)
61
61
  if err != nil {
62
- return nil, err
62
+ return nil, 0, err
63
63
  }
64
64
  out[i] = RoleListItem{Role: role, Permissions: perms}
65
65
  }
66
- return out, nil
66
+ return out, total, nil
67
67
  }
68
68
 
69
69
  var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
@@ -2,11 +2,14 @@ package application
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
5
6
  "errors"
6
7
  "net/http"
8
+ "strings"
7
9
  "testing"
8
10
 
9
11
  "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
10
13
  )
11
14
 
12
15
  // fakeRepo = mock of the repository interface, so the service can be tested
@@ -23,7 +26,9 @@ type fakeRepo struct {
23
26
  }
24
27
 
25
28
  func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
26
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.Role, error) { return nil, nil }
29
+ func (f *fakeRepo) FindAll(context.Context, ports.ListFilter) ([]domain.Role, int64, error) {
30
+ return nil, 0, nil
31
+ }
27
32
  func (f *fakeRepo) FindByCode(context.Context, string) (*domain.Role, error) {
28
33
  if f.findErr != nil {
29
34
  return nil, f.findErr
@@ -124,3 +129,16 @@ func TestService_Delete_SystemRoleRejected(t *testing.T) {
124
129
  t.Fatalf("want 409 for a system role, got %d", got)
125
130
  }
126
131
  }
132
+
133
+ func TestToRoleResponse_EmptyPermissionsMarshalAsArray(t *testing.T) {
134
+ // The repository's Pluck leaves the slice nil for a role with no grants,
135
+ // and `null` breaks any client that treats permissions as a list — which
136
+ // is every one of them, since the seeded `staff` role starts empty.
137
+ body, err := json.Marshal(ToRoleResponse(RoleListItem{Role: domain.Role{Code: "staff"}}))
138
+ if err != nil {
139
+ t.Fatalf("marshal: %v", err)
140
+ }
141
+ if !strings.Contains(string(body), `"Permissions":[]`) {
142
+ t.Fatalf("want an empty array for a role with no permissions, got %s", body)
143
+ }
144
+ }
@@ -7,9 +7,21 @@ import (
7
7
  "{{goModule}}/internal/app/role/domain"
8
8
  )
9
9
 
10
+ // ListFilter is the whole question the role list asks. The zero value is "the
11
+ // first page of everything", so a caller only sets what it narrows by. Put new
12
+ // filters here as fields rather than as arguments — see
13
+ // docs/architect/patterns.md.
14
+ type ListFilter struct {
15
+ // Search is ?q= as shared/pagination parsed it, matched against the code
16
+ // and the display name.
17
+ Search string
18
+ Limit int
19
+ Offset int
20
+ }
21
+
10
22
  type Repository interface {
11
23
  PermissionCodes(context.Context, string) ([]string, error)
12
- FindAll(context.Context, int, int) ([]domain.Role, error)
24
+ FindAll(context.Context, ListFilter) ([]domain.Role, int64, error)
13
25
  FindByCode(context.Context, string) (*domain.Role, error)
14
26
  Create(context.Context, *domain.Role) error
15
27
  SetPermissions(context.Context, string, []string) error
@@ -172,7 +172,7 @@ method:
172
172
 
173
173
  | Type | Shape | Generated behavior |
174
174
  |---|---|---|
175
- | get --get-mode all | GET /<plural>/<name> | Reuses FindAll; add real filtering |
175
+ | get --get-mode all | GET /<plural>/<name> | Reuses FindAll (page + total, `?q=` parsed); add fields to ports.ListFilter |
176
176
  | get --get-mode one --field <f> | GET /<plural>/<f>/:<f> | Adds a repository lookup and test seam |
177
177
  | post | POST /<plural>/<name> | Body DTO plus internal-error TODO |
178
178
  | put / patch | <VERB> /<plural>/:id/<name> | Safe 501 Not Implemented; no repository read/write |
@@ -102,6 +102,13 @@ go-scaffold generate module <name> --defaults
102
102
  go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
103
103
  ```
104
104
 
105
+ `generate method` extends the modules `generate module` created, and the one
106
+ `add auth` owns. It refuses `add rbac`'s: that module builds its response from
107
+ a role *together with its permissions* rather than from the entity, and the
108
+ generator has no shape to write against there. Add an endpoint to it by hand —
109
+ a route in `internal/app/role/adapters/inbound/http/handler.go`, a method on
110
+ the application service, and its entry in `docs/`.
111
+
105
112
  Pass every value as a flag in CI. In an interactive shell, omitted values use
106
113
  the project defaults as the initial wizard choices. In a non-interactive
107
114
  shell, omitted values prompt and the command exits without writing.
@@ -142,7 +149,8 @@ These remain deliberate engineering work:
142
149
  - any work in a repository that lacks `go-scaffold.config.json`.
143
150
 
144
151
  Generated methods are explicit stubs. `generate method` GET-all reuses
145
- `FindAll` until real filtering is added; POST and DELETE return an internal
152
+ `FindAll`, so it answers the same rows as the module's own list until
153
+ `ports.ListFilter` grows the fields that endpoint narrows by; POST and DELETE return an internal
146
154
  error until implemented; PUT/PATCH intentionally return `501 Not Implemented`
147
155
  and must not read or write a record before their behavior is designed. Treat
148
156
  the generated OpenAPI TODO contract as incomplete, not as a promise that the
@@ -50,10 +50,11 @@ internal/
50
50
  │ ├── config/ # loads config from env
51
51
  │ ├── apperror/ # central error type (status + payload)
52
52
  │ ├── dberr/ # maps DB errors to constraint kind — shared by every domain
53
+ │ ├── dbq/ # the SQL every list repeats: escaped contains-search
53
54
  │ ├── httpx/ # HTTP helpers shared by every domain
54
55
  │ ├── id/ # UUID v7 generation (id.New)
55
56
  │ ├── middleware/ # RequestID, Logger, Error, CORS{{#if auth}}, auth{{/if}}{{#if rbac}}, RBAC{{/if}}{{#if observability}}, metrics, tracing{{/if}}
56
- │ ├── pagination/ # parses ?limit=&offset=, response envelope
57
+ │ ├── pagination/ # parses ?limit=&offset=&q=, response envelope
57
58
  │ └── tx/ # carries a transaction on the context
58
59
  └── app/ # one package per domain, added with `generate module`
59
60
  ```
@@ -0,0 +1,47 @@
1
+ // Package dbq holds the SQL every list endpoint repeats, so a filter is not
2
+ // re-derived — and re-broken — once per module.
3
+ package dbq
4
+
5
+ import (
6
+ "strings"
7
+
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ // likeEscaper protects the pattern's own metacharacters. Backslash first, or
12
+ // it would escape the backslashes the other two just added.
13
+ var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
14
+
15
+ // LikePattern turns a search term into a `contains` pattern with the wildcards
16
+ // escaped, so searching for "50%" looks for the three characters "50%" rather
17
+ // than for anything starting with 50.
18
+ //
19
+ // Postgres already treats backslash as LIKE's escape character, so the pattern
20
+ // needs no trailing ESCAPE clause.
21
+ func LikePattern(term string) string {
22
+ return "%" + likeEscaper.Replace(term) + "%"
23
+ }
24
+
25
+ // Search narrows db to rows where any of cols contains term, ignoring case.
26
+ // An empty term returns db untouched, so a caller applies it unconditionally:
27
+ //
28
+ // q = dbq.Search(q, filter.Search, "name", "code")
29
+ //
30
+ // cols are SQL identifiers written by the caller. Never pass one that came off
31
+ // the request — they are interpolated; only the term is a bound parameter.
32
+ func Search(db *gorm.DB, term string, cols ...string) *gorm.DB {
33
+ if term == "" || len(cols) == 0 {
34
+ return db
35
+ }
36
+ pattern := LikePattern(term)
37
+ conds := make([]string, len(cols))
38
+ args := make([]any, len(cols))
39
+ for i, col := range cols {
40
+ conds[i] = col + " ILIKE ?"
41
+ args[i] = pattern
42
+ }
43
+ // Parenthesised here rather than trusting the driver to group it: ANDed
44
+ // with a second filter, an unwrapped OR would widen the result instead of
45
+ // narrowing it.
46
+ return db.Where("("+strings.Join(conds, " OR ")+")", args...)
47
+ }
@@ -0,0 +1,66 @@
1
+ package dbq
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "gorm.io/driver/postgres"
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ func TestLikePatternEscapesWildcards(t *testing.T) {
12
+ // A term the user typed is data, not pattern: each of these three would
13
+ // otherwise silently widen the search.
14
+ for term, want := range map[string]string{
15
+ "nake": "%nake%",
16
+ "50%": `%50\%%`,
17
+ "a_b": `%a\_b%`,
18
+ `c:\tmp`: `%c:\\tmp%`,
19
+ } {
20
+ if got := LikePattern(term); got != want {
21
+ t.Errorf("LikePattern(%q) = %q, want %q", term, got, want)
22
+ }
23
+ }
24
+ }
25
+
26
+ // dryRunDB builds SQL without a server, so this stays a unit test.
27
+ func dryRunDB(t *testing.T) *gorm.DB {
28
+ t.Helper()
29
+ db, err := gorm.Open(postgres.New(postgres.Config{DSN: "postgres://x/y"}), &gorm.Config{
30
+ DryRun: true,
31
+ DisableAutomaticPing: true,
32
+ })
33
+ if err != nil {
34
+ t.Fatalf("open dry-run database: %v", err)
35
+ }
36
+ return db
37
+ }
38
+
39
+ type row struct {
40
+ ID int
41
+ Name string
42
+ Code string
43
+ }
44
+
45
+ func TestSearchGroupsItsOrAndKeepsTheTermBound(t *testing.T) {
46
+ db := dryRunDB(t)
47
+ stmt := Search(db.Model(&row{}).Where("id = ?", 1), "ab", "name", "code").Find(&[]row{}).Statement
48
+ sql := stmt.SQL.String()
49
+
50
+ // Unparenthesised, `id = 1 AND name ILIKE ... OR code ILIKE ...` matches
51
+ // every row whose code hits — the filter beside it stops applying.
52
+ if !strings.Contains(sql, "(name ILIKE $2 OR code ILIKE $3)") {
53
+ t.Errorf("OR is not grouped: %s", sql)
54
+ }
55
+ if strings.Contains(sql, "ab") {
56
+ t.Errorf("search term was interpolated, not bound: %s", sql)
57
+ }
58
+ }
59
+
60
+ func TestSearchWithoutATermIsANoOp(t *testing.T) {
61
+ db := dryRunDB(t)
62
+ stmt := Search(db.Model(&row{}), "", "name").Find(&[]row{}).Statement
63
+ if strings.Contains(stmt.SQL.String(), "ILIKE") {
64
+ t.Errorf("empty term still filtered: %s", stmt.SQL.String())
65
+ }
66
+ }
@@ -2,6 +2,7 @@ package pagination
2
2
 
3
3
  import (
4
4
  "strconv"
5
+ "strings"
5
6
 
6
7
  "github.com/gin-gonic/gin"
7
8
  )
@@ -9,15 +10,23 @@ import (
9
10
  const (
10
11
  defaultLimit = 20
11
12
  maxLimit = 100
13
+ // Long enough for any name or address anyone types into a search box, and
14
+ // short enough that the LIKE it becomes cannot be used to make the
15
+ // database do real work.
16
+ maxSearchRunes = 100
12
17
  )
13
18
 
14
- // Params is a validated limit/offset pair (default 20, capped at 100).
19
+ // Params is a validated limit/offset pair (default 20, capped at 100) plus the
20
+ // free-text search the list was asked for.
15
21
  type Params struct {
16
22
  Limit int
17
23
  Offset int
24
+ // Search is ?q= trimmed, empty when the caller sent none — so a repository
25
+ // can pass it straight to dbq.Search, which no-ops on an empty term.
26
+ Search string
18
27
  }
19
28
 
20
- // Parse reads ?limit=&offset= off the request so every feature parses the
29
+ // Parse reads ?limit=&offset=&q= off the request so every feature parses the
21
30
  // same way.
22
31
  func Parse(c *gin.Context) Params {
23
32
  p := Params{Limit: defaultLimit}
@@ -30,6 +39,14 @@ func Parse(c *gin.Context) Params {
30
39
  if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
31
40
  p.Offset = v
32
41
  }
42
+ // Runes, not bytes: one non-Latin character is often three bytes, and
43
+ // cutting one in half would send invalid UTF-8 into the query.
44
+ if q := strings.TrimSpace(c.Query("q")); q != "" {
45
+ if r := []rune(q); len(r) > maxSearchRunes {
46
+ q = string(r[:maxSearchRunes])
47
+ }
48
+ p.Search = q
49
+ }
33
50
  return p
34
51
  }
35
52
 
@@ -47,7 +47,7 @@ internal/
47
47
  ├── platform/ # talks to real external systems
48
48
  │ └── database/ # PostgreSQL connection and pool
49
49
  ├── shared/ # pure logic/framework glue, no I/O
50
- │ ├── config/ apperror/ dberr/ httpx/ id/
50
+ │ ├── config/ apperror/ dberr/ dbq/ httpx/ id/
51
51
  │ └── middleware/ pagination/ tx/
52
52
  └── app/ # domain packages (one per feature)
53
53
  └── <domain>/
@@ -129,12 +129,15 @@ call site, no error-shape drift between domains.
129
129
  - DB errors are classified once in `shared/dberr` (`IsDuplicate`,
130
130
  `IsForeignKey`) and mapped to the right HTTP status per domain
131
131
 
132
- ## 6. Pagination
132
+ ## 6. Pagination and Filtering
133
133
 
134
- **Decision:** Shared `limit`/`offset` parsing and response envelope
135
- (`shared/pagination`), used by every list endpoint.
136
- **Rationale:** One implementation, one response shape (`{data, limit,
137
- offset}`) no per-domain reinvention.
134
+ **Decision:** Shared `limit`/`offset`/`q` parsing and response envelope
135
+ (`shared/pagination`), used by every list endpoint, with the SQL a filter
136
+ repeats in `shared/dbq`. A list takes one filter struct from `ports/` and
137
+ answers a page plus its total.
138
+ **Rationale:** One implementation, one response shape (`{data, limit, offset,
139
+ total}`) — no per-domain reinvention, and no per-domain LIKE escaping to get
140
+ wrong. See `patterns.md`, "Filtered Lists".
138
141
 
139
142
  ## 7. Persistence
140
143
 
@@ -11,3 +11,8 @@ Offset:
11
11
  name: offset
12
12
  in: query
13
13
  schema: { type: integer, default: 0, minimum: 0 }
14
+ Search:
15
+ name: q
16
+ in: query
17
+ description: "free-text search; which columns it matches is documented per endpoint"
18
+ schema: { type: string, maxLength: 100 }
@@ -4,6 +4,7 @@ PageEnvelope:
4
4
  properties:
5
5
  limit: { type: integer }
6
6
  offset: { type: integer }
7
+ total: { type: integer, description: "rows the filter matched; present on endpoints that count them" }
7
8
 
8
9
  Error:
9
10
  type: object
@@ -250,6 +250,66 @@ those markers** — they're where the next `generate method` call inserts. The
250
250
  method body is always left as a compiling `TODO` rather than guessing at
251
251
  business logic — same spirit as `generate module`'s placeholder fields.
252
252
 
253
+ ## Filtered Lists — one filter struct, a page and its total
254
+
255
+ A `crud`-surface module is generated with this already wired; a module that
256
+ grows a list later copies the same four steps. Nothing about it is magic.
257
+
258
+ **1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset`
259
+ and `Search` (`?q=`, trimmed and capped at 100 runes). Read the filters this
260
+ endpoint owns off the query string beside it. An unreadable filter value means
261
+ *no* filter, not `400` — a list narrows on a best effort.
262
+
263
+ **2. One filter struct, in `ports/`.** `ports.ListFilter` travels whole through
264
+ handler → application → repository, so adding a filter later is one field and
265
+ not a fourth positional argument on three signatures. It lives in `ports/`
266
+ because that is the only package all three may import — `ports` importing
267
+ `application` would be a cycle. Its zero value means "the first page of
268
+ everything".
269
+
270
+ ```go
271
+ type ListFilter struct {
272
+ Search string
273
+ Limit int
274
+ Offset int
275
+ // Status, OwnerID, DateFrom … whatever this module actually filters by.
276
+ }
277
+ ```
278
+
279
+ **3. The repository returns the page *and* the total**, because a client that
280
+ pages needs both and only SQL can answer either:
281
+
282
+ ```go
283
+ FindAll(context.Context, ListFilter) ([]domain.Thing, int64, error)
284
+ ```
285
+
286
+ Build the conditions in a closure and call it once per query — conditions
287
+ accumulate on a `*gorm.DB` value, so a count reusing the page's query object
288
+ silently inherits its `LIMIT`. Use `dbq.Search(q, term, cols...)` for a
289
+ contains-search over columns on the same table, and `dbq.LikePattern(term)`
290
+ 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. Always
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.
295
+
296
+ The generated `FindAll` calls `dbq.Search` with no columns, which is a no-op:
297
+ `?q=` is accepted and ignored until you name the columns this list is searched
298
+ by. That TODO is the one line standing between the scaffold and a working
299
+ search.
300
+
301
+ **4. Respond with the shared envelope.** `p.ResponseWithTotal(out, total)` —
302
+ `{ data, limit, offset, total }`, the same shape for every resource. Anything
303
+ extra is a named key added to that map and documented in the endpoint's
304
+ OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit` and `#/Offset`
305
+ for the query parameters.
306
+
307
+ Counts that answer a *different* question from the page — "how many are open
308
+ and how many are closed, whichever tab is showing" — belong beside the
309
+ envelope, not in it, and are computed from the filter minus the field the tabs
310
+ switch. A tab that recounted itself when clicked would always read the same
311
+ number.
312
+
253
313
  ## Testing Conventions
254
314
 
255
315
  - Unit and integration tests live in the same directory as the code under
@@ -21,9 +21,10 @@ get:
21
21
  parameters:
22
22
  - $ref: '../common/parameters.yaml#/Limit'
23
23
  - $ref: '../common/parameters.yaml#/Offset'
24
+ - $ref: '../common/parameters.yaml#/Search'
24
25
  responses:
25
26
  "200":
26
- description: paginated list
27
+ description: "paginated list; narrow it with ?q= and whatever filters this module adds"
27
28
  content:
28
29
  application/json:
29
30
  schema: