@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.
- package/README.md +16 -2
- package/dist/commands/auth.js +6 -1
- package/dist/commands/check.js +5 -1
- package/dist/commands/method.js +27 -1
- package/dist/index.js +20 -6
- package/dist/prompts/auth-wizard.js +33 -1
- package/dist/templates/create-manifest.js +8 -0
- package/dist/utils/hexagonal-method-patcher.js +79 -20
- package/package.json +1 -1
- package/templates/add/auth/docs/login.yaml.hbs +9 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
- package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
- package/templates/add/rbac/docs/users.yaml.hbs +2 -1
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +6 -2
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +9 -1
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
- package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
- package/templates/create/base/AGENTS.md.hbs +9 -1
- package/templates/create/base/README.md.hbs +2 -1
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
- package/templates/create/features/docs/architecture.md.hbs +9 -6
- package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
- package/templates/create/features/docs/patterns.md.hbs +60 -0
- package/templates/generate/module/docs/collection.yaml.hbs +2 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
54
|
-
roles, err := s.repo.FindAll(ctx,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
|
@@ -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:
|