@apso/cli 0.35.0 → 0.37.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/dist/lib/templates/go/handlers/handler.eta +35 -1
- package/dist/lib/templates/go/services/service.eta +33 -1
- package/dist/lib/templates/go/utils/query.eta +664 -10
- package/dist/lib/templates/go/utils/types.eta +47 -2
- package/dist/lib/templates/python/routers/router.eta +51 -9
- package/dist/lib/templates/python/services/service.eta +7 -0
- package/dist/lib/templates/python/utils/init.eta +10 -0
- package/dist/lib/templates/python/utils/query.eta +613 -60
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
package utils
|
|
4
4
|
|
|
5
5
|
import (
|
|
6
|
+
"errors"
|
|
6
7
|
"fmt"
|
|
7
8
|
"math"
|
|
8
9
|
"net/url"
|
|
10
|
+
"regexp"
|
|
9
11
|
"strconv"
|
|
10
12
|
"strings"
|
|
11
13
|
|
|
@@ -18,9 +20,137 @@ const (
|
|
|
18
20
|
DefaultPage = 1
|
|
19
21
|
)
|
|
20
22
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
+
// ErrQuery is a client-facing (HTTP 400) query error. The generated handler
|
|
24
|
+
// maps any *QueryError to a 400 response so a malformed dialect request never
|
|
25
|
+
// surfaces as a 500 (apso #60).
|
|
26
|
+
type QueryError struct{ Msg string }
|
|
27
|
+
|
|
28
|
+
func (e *QueryError) Error() string { return e.Msg }
|
|
29
|
+
|
|
30
|
+
func queryErr(format string, a ...interface{}) *QueryError {
|
|
31
|
+
return &QueryError{Msg: fmt.Sprintf(format, a...)}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// AsQueryError reports whether err is a client-facing *QueryError.
|
|
35
|
+
func AsQueryError(err error) (*QueryError, bool) {
|
|
36
|
+
var qe *QueryError
|
|
37
|
+
if errors.As(err, &qe) {
|
|
38
|
+
return qe, true
|
|
39
|
+
}
|
|
40
|
+
return nil, false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Dialect detection (mirrors @apso/crud dialect.ts).
|
|
45
|
+
//
|
|
46
|
+
// Precedence:
|
|
47
|
+
// 1. X-Crud-Dialect header — explicit, always wins (postgrest|nestjsx else 400).
|
|
48
|
+
// 2. Param-shape signals:
|
|
49
|
+
// - nestjsx-only keys: fields, filter, or, join, sort, s, per_page
|
|
50
|
+
// - PostgREST-only keys: select, order
|
|
51
|
+
// - a bare `col=op.value` param (value matches a PostgREST operator)
|
|
52
|
+
// is a PostgREST signal
|
|
53
|
+
// - limit/offset/page/cache are dialect-neutral (shared)
|
|
54
|
+
// 3. Both families present => 400 (do NOT guess).
|
|
55
|
+
// 4. Only PostgREST signals => postgrest.
|
|
56
|
+
// 5. Otherwise (nestjsx signals, neutral-only, or empty) => nestjsx (incumbent).
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
var nestjsxKeys = map[string]bool{
|
|
60
|
+
"fields": true, "filter": true, "or": true,
|
|
61
|
+
"join": true, "sort": true, "s": true, "per_page": true,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var postgrestKeys = map[string]bool{"select": true, "order": true}
|
|
65
|
+
|
|
66
|
+
var neutralKeys = map[string]bool{
|
|
67
|
+
"limit": true, "offset": true, "page": true, "cache": true,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// postgrestOpRe recognizes the SHAPE of a bare PostgREST column filter value:
|
|
71
|
+
// `op.value` or `not.op.value`. Unknown operators are rejected later by the
|
|
72
|
+
// parser; detection only needs to recognize the shape.
|
|
73
|
+
var postgrestOpRe = regexp.MustCompile(
|
|
74
|
+
`(?i)^(not\.)?(eq|neq|gt|gte|lt|lte|like|ilike|match|imatch|in|is|isdistinct|fts|plfts|phfts|wfts|cs|cd|ov|sl|sr|nxr|nxl|adj)\.`,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// DetectDialect picks the dialect for a request from the X-Crud-Dialect header
|
|
78
|
+
// and the query param shape. Returns a *QueryError (400) on an invalid header
|
|
79
|
+
// or an ambiguous mixed-dialect query.
|
|
80
|
+
func DetectDialect(params url.Values, dialectHeader string) (Dialect, error) {
|
|
81
|
+
// 1. Explicit header override — always wins.
|
|
82
|
+
if h := strings.TrimSpace(strings.ToLower(dialectHeader)); h != "" {
|
|
83
|
+
switch h {
|
|
84
|
+
case "postgrest":
|
|
85
|
+
return DialectPostgrest, nil
|
|
86
|
+
case "nestjsx":
|
|
87
|
+
return DialectNestjsx, nil
|
|
88
|
+
default:
|
|
89
|
+
return DialectNestjsx, queryErr(
|
|
90
|
+
"Invalid X-Crud-Dialect header '%s' (expected 'postgrest' or 'nestjsx').",
|
|
91
|
+
dialectHeader,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Param-shape signals.
|
|
97
|
+
nestjsx := false
|
|
98
|
+
postgrest := false
|
|
99
|
+
for key, vals := range params {
|
|
100
|
+
switch {
|
|
101
|
+
case nestjsxKeys[key]:
|
|
102
|
+
nestjsx = true
|
|
103
|
+
case postgrestKeys[key]:
|
|
104
|
+
postgrest = true
|
|
105
|
+
case neutralKeys[key]:
|
|
106
|
+
continue
|
|
107
|
+
default:
|
|
108
|
+
// A bare `col=op.value` is the only bare-param dialect signal.
|
|
109
|
+
if len(vals) > 0 && postgrestOpRe.MatchString(vals[0]) {
|
|
110
|
+
postgrest = true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Genuine collision — refuse to guess.
|
|
116
|
+
if nestjsx && postgrest {
|
|
117
|
+
return DialectNestjsx, queryErr(
|
|
118
|
+
"Ambiguous query: it mixes nestjsx params (fields/filter/join/sort/s/or) " +
|
|
119
|
+
"with PostgREST params (select/order or col=op.value). Send a single " +
|
|
120
|
+
"dialect, or set the 'X-Crud-Dialect' header to force one.",
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 4/5. One family, or neutral-only/empty (default to the incumbent nestjsx).
|
|
125
|
+
if postgrest {
|
|
126
|
+
return DialectPostgrest, nil
|
|
127
|
+
}
|
|
128
|
+
return DialectNestjsx, nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ParseQueryParams parses standard (nestjsx) query parameters from a url.Values
|
|
132
|
+
// map into a QueryOptions struct. Retained for backward compatibility; the
|
|
133
|
+
// generated handler calls ParseQuery (dialect-aware) instead.
|
|
23
134
|
func ParseQueryParams(params url.Values) QueryOptions {
|
|
135
|
+
return parseNestjsxParams(params)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ParseQuery detects the request dialect from the X-Crud-Dialect header and the
|
|
139
|
+
// query param shape, then parses with the matching parser. On a bad header or
|
|
140
|
+
// an ambiguous mixed-dialect query it returns a *QueryError (400).
|
|
141
|
+
func ParseQuery(params url.Values, dialectHeader string) (QueryOptions, error) {
|
|
142
|
+
dialect, err := DetectDialect(params, dialectHeader)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return DefaultQueryOptions(), err
|
|
145
|
+
}
|
|
146
|
+
if dialect == DialectPostgrest {
|
|
147
|
+
return parsePostgrestParams(params)
|
|
148
|
+
}
|
|
149
|
+
return parseNestjsxParams(params), nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// parseNestjsxParams parses the incumbent nestjsx/crud query convention.
|
|
153
|
+
func parseNestjsxParams(params url.Values) QueryOptions {
|
|
24
154
|
opts := DefaultQueryOptions()
|
|
25
155
|
|
|
26
156
|
// Filters: ?filter=field||$op||value (repeatable)
|
|
@@ -80,21 +210,456 @@ func ParseQueryParams(params url.Values) QueryOptions {
|
|
|
80
210
|
return opts
|
|
81
211
|
}
|
|
82
212
|
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// PostgREST parser (mirrors @apso/postgrest-request postgrest-parser.ts).
|
|
215
|
+
//
|
|
216
|
+
// Supported surface (apso #36/#56-#60):
|
|
217
|
+
// - select=a,b column projection (+ alias:col rename #57)
|
|
218
|
+
// - col=op.value filters: eq/neq/gt/gte/lt/lte/like/ilike/in/is
|
|
219
|
+
// and not.<op>
|
|
220
|
+
// - or=(...) / and=(...) logical combinators (#56)
|
|
221
|
+
// - order=col.asc|desc[.nullsfirst|nullslast]
|
|
222
|
+
// - limit / offset pagination -> bare JSON array response (#58)
|
|
223
|
+
//
|
|
224
|
+
// Deferred (apso #54), rejected clearly: cs, cd, ov, fts and other advanced
|
|
225
|
+
// operators. Resource embedding (select=rel(cols)) columns are parsed as a
|
|
226
|
+
// join preload where the Go path supports it; embedded ON-clause filters are
|
|
227
|
+
// NOT applied here (the GORM Preload path has no embed-level predicate) — an
|
|
228
|
+
// `rel.col=` filter is treated as a root filter. See the PR notes.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
// postgrestReserved are query keys that are NOT column filters.
|
|
232
|
+
var postgrestReserved = map[string]bool{
|
|
233
|
+
"select": true, "order": true, "limit": true,
|
|
234
|
+
"offset": true, "and": true, "or": true, "cache": true, "page": true,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// postgrestOpMap maps a PostgREST operator to the internal $-operator the GORM
|
|
238
|
+
// applyOperator switch understands.
|
|
239
|
+
var postgrestOpMap = map[string]string{
|
|
240
|
+
"eq": "$eq", "neq": "$ne", "gt": "$gt", "gte": "$gte",
|
|
241
|
+
"lt": "$lt", "lte": "$lte", "like": "$like", "ilike": "$ilike",
|
|
242
|
+
"in": "$in",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// postgrestNotMap maps not.<op> to the negated internal operator. The
|
|
246
|
+
// like/ilike negations use the postgrest-specific $nlike/$nilike operators,
|
|
247
|
+
// which apply the pattern verbatim (the wildcards are already `*`->`%`
|
|
248
|
+
// translated by the parser) rather than the nestjsx $excl/$exclL which wrap the
|
|
249
|
+
// value in %...%.
|
|
250
|
+
var postgrestNotMap = map[string]string{
|
|
251
|
+
"eq": "$ne", "neq": "$eq", "in": "$notin",
|
|
252
|
+
"like": "$nlike", "ilike": "$nilike",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// deferredPostgrestOps are recognized-but-unsupported operators (apso #54).
|
|
256
|
+
var deferredPostgrestOps = map[string]bool{
|
|
257
|
+
"cs": true, "cd": true, "ov": true, "fts": true, "plfts": true,
|
|
258
|
+
"phfts": true, "wfts": true, "match": true, "imatch": true,
|
|
259
|
+
"isdistinct": true, "sl": true, "sr": true, "nxr": true,
|
|
260
|
+
"nxl": true, "adj": true,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func parsePostgrestParams(params url.Values) (QueryOptions, error) {
|
|
264
|
+
opts := DefaultQueryOptions()
|
|
265
|
+
opts.Dialect = DialectPostgrest
|
|
266
|
+
|
|
267
|
+
// select=a,b (+ alias:col rename)
|
|
268
|
+
if sel := params.Get("select"); sel != "" {
|
|
269
|
+
fields, joins, err := parsePostgrestSelect(sel)
|
|
270
|
+
if err != nil {
|
|
271
|
+
return opts, err
|
|
272
|
+
}
|
|
273
|
+
opts.Fields = fields
|
|
274
|
+
opts.Joins = joins
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// order=col.dir[.nullsX],...
|
|
278
|
+
if ord := params.Get("order"); ord != "" {
|
|
279
|
+
sorts, err := parsePostgrestOrder(ord)
|
|
280
|
+
if err != nil {
|
|
281
|
+
return opts, err
|
|
282
|
+
}
|
|
283
|
+
opts.Sorts = sorts
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// limit / offset. Presence of either => bare array response (#58).
|
|
287
|
+
if limitRaw := params.Get("limit"); limitRaw != "" {
|
|
288
|
+
v, err := strconv.Atoi(limitRaw)
|
|
289
|
+
if err != nil {
|
|
290
|
+
return opts, queryErr("Invalid limit '%s'", limitRaw)
|
|
291
|
+
}
|
|
292
|
+
if v > MaxLimit {
|
|
293
|
+
v = MaxLimit
|
|
294
|
+
}
|
|
295
|
+
if v > 0 {
|
|
296
|
+
opts.Limit = v
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if offsetRaw := params.Get("offset"); offsetRaw != "" {
|
|
300
|
+
v, err := strconv.Atoi(offsetRaw)
|
|
301
|
+
if err != nil {
|
|
302
|
+
return opts, queryErr("Invalid offset '%s'", offsetRaw)
|
|
303
|
+
}
|
|
304
|
+
if v < 0 {
|
|
305
|
+
v = 0
|
|
306
|
+
}
|
|
307
|
+
opts.Offset = v
|
|
308
|
+
}
|
|
309
|
+
// PostgREST always returns a bare JSON array, regardless of limit/offset.
|
|
310
|
+
opts.Bare = true
|
|
311
|
+
|
|
312
|
+
// or=(...) / and=(...) logical combinators (#56).
|
|
313
|
+
if raw := params.Get("or"); raw != "" {
|
|
314
|
+
g, err := parsePostgrestGroup(raw, "OR")
|
|
315
|
+
if err != nil {
|
|
316
|
+
return opts, err
|
|
317
|
+
}
|
|
318
|
+
opts.Groups = append(opts.Groups, g)
|
|
319
|
+
}
|
|
320
|
+
if raw := params.Get("and"); raw != "" {
|
|
321
|
+
g, err := parsePostgrestGroup(raw, "AND")
|
|
322
|
+
if err != nil {
|
|
323
|
+
return opts, err
|
|
324
|
+
}
|
|
325
|
+
opts.Groups = append(opts.Groups, g)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Column filters: every non-reserved key is `col=op.value`.
|
|
329
|
+
for key, vals := range params {
|
|
330
|
+
if postgrestReserved[key] {
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
for _, v := range vals {
|
|
334
|
+
f, err := parsePostgrestCondition(key, v)
|
|
335
|
+
if err != nil {
|
|
336
|
+
return opts, err
|
|
337
|
+
}
|
|
338
|
+
opts.Filters = append(opts.Filters, *f)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return opts, nil
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// parsePostgrestSelect parses select=col,alias:col,rel(cols). Plain columns
|
|
346
|
+
// (with optional `alias:col` rename #57 and `::cast` stripped) become the field
|
|
347
|
+
// list; `rel(...)` embeds become join preloads.
|
|
348
|
+
func parsePostgrestSelect(sel string) ([]string, []ParsedJoin, error) {
|
|
349
|
+
var fields []string
|
|
350
|
+
var joins []ParsedJoin
|
|
351
|
+
for _, raw := range splitTopLevel(sel) {
|
|
352
|
+
tok := strings.TrimSpace(raw)
|
|
353
|
+
if tok == "" {
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
open := strings.IndexByte(tok, '(')
|
|
357
|
+
if open == -1 {
|
|
358
|
+
// plain column: drop `::type` cast, take column from `alias:col`.
|
|
359
|
+
noCast := strings.SplitN(tok, "::", 2)[0]
|
|
360
|
+
parts := strings.Split(noCast, ":")
|
|
361
|
+
col := strings.TrimSpace(parts[len(parts)-1])
|
|
362
|
+
if col == "*" || col == "" {
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
if len(parts) > 1 {
|
|
366
|
+
alias := strings.TrimSpace(parts[0])
|
|
367
|
+
if alias != "" && alias != col {
|
|
368
|
+
// `col AS alias` output rename (#57).
|
|
369
|
+
fields = append(fields, fmt.Sprintf("%s AS %s", col, alias))
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
fields = append(fields, col)
|
|
374
|
+
} else {
|
|
375
|
+
// embed: `[alias:]relation[!hint](innerSelect)`
|
|
376
|
+
head := tok[:open]
|
|
377
|
+
relPart := head
|
|
378
|
+
if strings.Contains(head, ":") {
|
|
379
|
+
hp := strings.Split(head, ":")
|
|
380
|
+
relPart = hp[len(hp)-1]
|
|
381
|
+
}
|
|
382
|
+
rel := strings.TrimSpace(strings.Split(relPart, "!")[0])
|
|
383
|
+
if rel == "" {
|
|
384
|
+
return nil, nil, queryErr("Invalid embed in select: %s", tok)
|
|
385
|
+
}
|
|
386
|
+
inner := ""
|
|
387
|
+
if close := strings.LastIndexByte(tok, ')'); close > open {
|
|
388
|
+
inner = tok[open+1 : close]
|
|
389
|
+
}
|
|
390
|
+
j := ParsedJoin{Relation: rel}
|
|
391
|
+
for _, sub := range splitTopLevel(inner) {
|
|
392
|
+
c := strings.TrimSpace(sub)
|
|
393
|
+
if c == "" || c == "*" || strings.Contains(c, "(") {
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
c = strings.SplitN(c, "::", 2)[0]
|
|
397
|
+
sp := strings.Split(c, ":")
|
|
398
|
+
j.Fields = append(j.Fields, strings.TrimSpace(sp[len(sp)-1]))
|
|
399
|
+
}
|
|
400
|
+
joins = append(joins, j)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return fields, joins, nil
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// parsePostgrestOrder parses order=col.dir[.nullsfirst|nullslast],... .
|
|
407
|
+
func parsePostgrestOrder(order string) ([]ParsedSort, error) {
|
|
408
|
+
var sorts []ParsedSort
|
|
409
|
+
for _, raw := range splitTopLevel(order) {
|
|
410
|
+
t := strings.TrimSpace(raw)
|
|
411
|
+
if t == "" {
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
parts := strings.Split(t, ".")
|
|
415
|
+
field := strings.TrimSpace(parts[0])
|
|
416
|
+
if field == "" {
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
dir := "ASC"
|
|
420
|
+
nulls := ""
|
|
421
|
+
for _, mod := range parts[1:] {
|
|
422
|
+
switch strings.ToLower(strings.TrimSpace(mod)) {
|
|
423
|
+
case "asc":
|
|
424
|
+
dir = "ASC"
|
|
425
|
+
case "desc":
|
|
426
|
+
dir = "DESC"
|
|
427
|
+
case "nullsfirst":
|
|
428
|
+
nulls = "NULLS FIRST"
|
|
429
|
+
case "nullslast":
|
|
430
|
+
nulls = "NULLS LAST"
|
|
431
|
+
case "":
|
|
432
|
+
// tolerate stray dot
|
|
433
|
+
default:
|
|
434
|
+
return nil, queryErr("Invalid order modifier: %s", mod)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
sorts = append(sorts, ParsedSort{Field: field, Direction: dir, Nulls: nulls})
|
|
438
|
+
}
|
|
439
|
+
return sorts, nil
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// parsePostgrestGroup parses an or=(...)/and=(...) group. Each branch is a
|
|
443
|
+
// `col.op.value` condition or a nested or(...)/and(...) group.
|
|
444
|
+
func parsePostgrestGroup(raw, conj string) (FilterGroup, error) {
|
|
445
|
+
label := "or"
|
|
446
|
+
if conj == "AND" {
|
|
447
|
+
label = "and"
|
|
448
|
+
}
|
|
449
|
+
trimmed := strings.TrimSpace(raw)
|
|
450
|
+
if !(strings.HasPrefix(trimmed, "(") && strings.HasSuffix(trimmed, ")")) {
|
|
451
|
+
return FilterGroup{}, queryErr("Invalid %s= group: expected %s=(cond,cond,...)", label, label)
|
|
452
|
+
}
|
|
453
|
+
inner := trimmed[1 : len(trimmed)-1]
|
|
454
|
+
group := FilterGroup{Conjunction: conj}
|
|
455
|
+
branches := splitTopLevel(inner)
|
|
456
|
+
nonEmpty := 0
|
|
457
|
+
for _, b := range branches {
|
|
458
|
+
part := strings.TrimSpace(b)
|
|
459
|
+
if part == "" {
|
|
460
|
+
continue
|
|
461
|
+
}
|
|
462
|
+
nonEmpty++
|
|
463
|
+
switch {
|
|
464
|
+
case strings.HasPrefix(part, "or("):
|
|
465
|
+
sub, err := parsePostgrestGroup(part[2:], "OR")
|
|
466
|
+
if err != nil {
|
|
467
|
+
return FilterGroup{}, err
|
|
468
|
+
}
|
|
469
|
+
group.Groups = append(group.Groups, sub)
|
|
470
|
+
case strings.HasPrefix(part, "and("):
|
|
471
|
+
sub, err := parsePostgrestGroup(part[3:], "AND")
|
|
472
|
+
if err != nil {
|
|
473
|
+
return FilterGroup{}, err
|
|
474
|
+
}
|
|
475
|
+
group.Groups = append(group.Groups, sub)
|
|
476
|
+
default:
|
|
477
|
+
dot := strings.IndexByte(part, '.')
|
|
478
|
+
if dot == -1 {
|
|
479
|
+
return FilterGroup{}, queryErr("Invalid condition in %s= group: %s", label, part)
|
|
480
|
+
}
|
|
481
|
+
f, err := parsePostgrestCondition(part[:dot], part[dot+1:])
|
|
482
|
+
if err != nil {
|
|
483
|
+
return FilterGroup{}, err
|
|
484
|
+
}
|
|
485
|
+
group.Filters = append(group.Filters, *f)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if nonEmpty == 0 {
|
|
489
|
+
return FilterGroup{}, queryErr("Empty %s= group", label)
|
|
490
|
+
}
|
|
491
|
+
return group, nil
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// parsePostgrestCondition parses `op.value` (or `not.op.value`) for a column
|
|
495
|
+
// into a ParsedFilter with an internal $-operator. `like`/`ilike` translate the
|
|
496
|
+
// PostgREST `*` wildcard to SQL `%`.
|
|
497
|
+
func parsePostgrestCondition(field, spec string) (*ParsedFilter, error) {
|
|
498
|
+
dot := strings.IndexByte(spec, '.')
|
|
499
|
+
if dot == -1 {
|
|
500
|
+
return nil, queryErr("Invalid filter for '%s': expected op.value", field)
|
|
501
|
+
}
|
|
502
|
+
op := spec[:dot]
|
|
503
|
+
rest := spec[dot+1:]
|
|
504
|
+
negated := false
|
|
505
|
+
if op == "not" {
|
|
506
|
+
negated = true
|
|
507
|
+
dot2 := strings.IndexByte(rest, '.')
|
|
508
|
+
if dot2 == -1 {
|
|
509
|
+
return nil, queryErr("Invalid 'not' filter for '%s'", field)
|
|
510
|
+
}
|
|
511
|
+
op = rest[:dot2]
|
|
512
|
+
rest = rest[dot2+1:]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if deferredPostgrestOps[op] {
|
|
516
|
+
return nil, queryErr(
|
|
517
|
+
"PostgREST operator '%s' is not yet supported (deferred). "+
|
|
518
|
+
"Supported: eq, neq, gt, gte, lt, lte, like, ilike, in, is (and not.<op>).",
|
|
519
|
+
op,
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
switch op {
|
|
524
|
+
case "is":
|
|
525
|
+
lit := strings.ToLower(rest)
|
|
526
|
+
switch lit {
|
|
527
|
+
case "null", "unknown":
|
|
528
|
+
operator := "$isnull"
|
|
529
|
+
if negated {
|
|
530
|
+
operator = "$notnull"
|
|
531
|
+
}
|
|
532
|
+
return &ParsedFilter{Field: field, Operator: operator, Value: ""}, nil
|
|
533
|
+
case "true", "false":
|
|
534
|
+
operator := "$eq"
|
|
535
|
+
if negated {
|
|
536
|
+
operator = "$ne"
|
|
537
|
+
}
|
|
538
|
+
return &ParsedFilter{Field: field, Operator: operator, Value: lit}, nil
|
|
539
|
+
default:
|
|
540
|
+
return nil, queryErr("Invalid 'is' value for '%s': %s", field, rest)
|
|
541
|
+
}
|
|
542
|
+
case "in":
|
|
543
|
+
operator := "$in"
|
|
544
|
+
if negated {
|
|
545
|
+
operator = "$notin"
|
|
546
|
+
}
|
|
547
|
+
return &ParsedFilter{Field: field, Operator: operator, Value: parsePostgrestInList(rest)}, nil
|
|
548
|
+
case "like", "ilike":
|
|
549
|
+
var operator string
|
|
550
|
+
if negated {
|
|
551
|
+
operator = postgrestNotMap[op]
|
|
552
|
+
} else {
|
|
553
|
+
operator = postgrestOpMap[op]
|
|
554
|
+
}
|
|
555
|
+
// PostgREST uses `*` as the % wildcard.
|
|
556
|
+
return &ParsedFilter{Field: field, Operator: operator, Value: strings.ReplaceAll(rest, "*", "%")}, nil
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
var operator string
|
|
560
|
+
if negated {
|
|
561
|
+
operator = postgrestNotMap[op]
|
|
562
|
+
} else {
|
|
563
|
+
operator = postgrestOpMap[op]
|
|
564
|
+
}
|
|
565
|
+
if operator == "" {
|
|
566
|
+
return nil, queryErr("Unsupported PostgREST operator '%s' for '%s'", op, field)
|
|
567
|
+
}
|
|
568
|
+
return &ParsedFilter{Field: field, Operator: operator, Value: rest}, nil
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// parsePostgrestInList parses in.(1,2,3) / in.("a,b",c) into a comma-joined
|
|
572
|
+
// value string (the internal $in operator re-splits on comma via splitValue).
|
|
573
|
+
func parsePostgrestInList(rest string) string {
|
|
574
|
+
inner := strings.TrimSpace(rest)
|
|
575
|
+
if strings.HasPrefix(inner, "(") && strings.HasSuffix(inner, ")") {
|
|
576
|
+
inner = inner[1 : len(inner)-1]
|
|
577
|
+
}
|
|
578
|
+
if inner == "" {
|
|
579
|
+
return ""
|
|
580
|
+
}
|
|
581
|
+
var out []string
|
|
582
|
+
var cur strings.Builder
|
|
583
|
+
quoted := false
|
|
584
|
+
for _, ch := range inner {
|
|
585
|
+
switch {
|
|
586
|
+
case ch == '"':
|
|
587
|
+
quoted = !quoted
|
|
588
|
+
case ch == ',' && !quoted:
|
|
589
|
+
out = append(out, strings.TrimSpace(cur.String()))
|
|
590
|
+
cur.Reset()
|
|
591
|
+
default:
|
|
592
|
+
cur.WriteRune(ch)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
out = append(out, strings.TrimSpace(cur.String()))
|
|
596
|
+
return strings.Join(out, ",")
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// splitTopLevel splits on commas that are NOT inside parentheses.
|
|
600
|
+
func splitTopLevel(s string) []string {
|
|
601
|
+
var out []string
|
|
602
|
+
depth := 0
|
|
603
|
+
var cur strings.Builder
|
|
604
|
+
for _, ch := range s {
|
|
605
|
+
switch ch {
|
|
606
|
+
case '(':
|
|
607
|
+
depth++
|
|
608
|
+
cur.WriteRune(ch)
|
|
609
|
+
case ')':
|
|
610
|
+
depth--
|
|
611
|
+
cur.WriteRune(ch)
|
|
612
|
+
case ',':
|
|
613
|
+
if depth == 0 {
|
|
614
|
+
out = append(out, cur.String())
|
|
615
|
+
cur.Reset()
|
|
616
|
+
} else {
|
|
617
|
+
cur.WriteRune(ch)
|
|
618
|
+
}
|
|
619
|
+
default:
|
|
620
|
+
cur.WriteRune(ch)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if strings.TrimSpace(cur.String()) != "" {
|
|
624
|
+
out = append(out, cur.String())
|
|
625
|
+
}
|
|
626
|
+
return out
|
|
627
|
+
}
|
|
628
|
+
|
|
83
629
|
// ApplyQuery applies filters, sorts, and pagination from QueryOptions to a
|
|
84
630
|
// GORM query. The allowedFields map restricts which column names can be used
|
|
85
631
|
// in filters and sorts to prevent injection.
|
|
86
632
|
func ApplyQuery(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (*gorm.DB, int64, int) {
|
|
633
|
+
q, total, pageCount, _ := ApplyQueryE(db, opts, allowedFields)
|
|
634
|
+
return q, total, pageCount
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ApplyQueryE is the dialect-aware form of ApplyQuery. It behaves identically to
|
|
638
|
+
// ApplyQuery for the nestjsx dialect (and never returns an error there). For the
|
|
639
|
+
// PostgREST dialect it enforces the postgrest semantics:
|
|
640
|
+
// - an unknown filter/sort/select column is a client error (*QueryError, 400)
|
|
641
|
+
// rather than a silently-dropped clause (apso #60);
|
|
642
|
+
// - ?or=()/?and=() groups are applied (apso #56);
|
|
643
|
+
// - order carries NULLS FIRST/LAST;
|
|
644
|
+
// - limit/offset window a BARE array — no COUNT round-trip (apso #58), and
|
|
645
|
+
// total/pageCount are returned as 0/1 (the handler ignores them for the
|
|
646
|
+
// bare response).
|
|
647
|
+
func ApplyQueryE(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (*gorm.DB, int64, int, error) {
|
|
87
648
|
query := db
|
|
649
|
+
postgrest := opts.Dialect == DialectPostgrest
|
|
88
650
|
|
|
89
651
|
// AND filters
|
|
90
652
|
for _, f := range opts.Filters {
|
|
91
653
|
if !allowedFields[f.Field] {
|
|
654
|
+
if postgrest {
|
|
655
|
+
return query, 0, 1, queryErr("Unknown column '%s' in query", f.Field)
|
|
656
|
+
}
|
|
92
657
|
continue
|
|
93
658
|
}
|
|
94
659
|
query = applyOperator(query, f.Field, f.Operator, f.Value, false)
|
|
95
660
|
}
|
|
96
661
|
|
|
97
|
-
// OR filters
|
|
662
|
+
// OR filters (nestjsx flat OR).
|
|
98
663
|
if len(opts.OrFilters) > 0 {
|
|
99
664
|
query = query.Where(func(tx *gorm.DB) *gorm.DB {
|
|
100
665
|
first := true
|
|
@@ -113,29 +678,58 @@ func ApplyQuery(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (
|
|
|
113
678
|
}(db.Session(&gorm.Session{NewDB: true})))
|
|
114
679
|
}
|
|
115
680
|
|
|
116
|
-
//
|
|
681
|
+
// PostgREST ?or=()/?and=() logical groups (#56). Each group is ANDed with
|
|
682
|
+
// the flat filters above.
|
|
683
|
+
for _, g := range opts.Groups {
|
|
684
|
+
expr, err := buildGroupExpr(db, g, allowedFields)
|
|
685
|
+
if err != nil {
|
|
686
|
+
return query, 0, 1, err
|
|
687
|
+
}
|
|
688
|
+
if expr != nil {
|
|
689
|
+
query = query.Where(expr)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Count before pagination — skipped for the bare (PostgREST) response,
|
|
694
|
+
// which never emits a total (#58).
|
|
117
695
|
var total int64
|
|
118
|
-
|
|
119
|
-
|
|
696
|
+
if !opts.Bare {
|
|
697
|
+
countQuery := query.Session(&gorm.Session{})
|
|
698
|
+
countQuery.Count(&total)
|
|
699
|
+
}
|
|
120
700
|
|
|
121
701
|
// Sorting
|
|
122
702
|
for _, s := range opts.Sorts {
|
|
123
703
|
if !allowedFields[s.Field] {
|
|
704
|
+
if postgrest {
|
|
705
|
+
return query, 0, 1, queryErr("Unknown column '%s' in order", s.Field)
|
|
706
|
+
}
|
|
124
707
|
continue
|
|
125
708
|
}
|
|
126
709
|
dir := "ASC"
|
|
127
710
|
if strings.EqualFold(s.Direction, "DESC") {
|
|
128
711
|
dir = "DESC"
|
|
129
712
|
}
|
|
130
|
-
|
|
713
|
+
clause := fmt.Sprintf("%s %s", s.Field, dir)
|
|
714
|
+
if s.Nulls != "" {
|
|
715
|
+
clause = fmt.Sprintf("%s %s", clause, s.Nulls)
|
|
716
|
+
}
|
|
717
|
+
query = query.Order(clause)
|
|
131
718
|
}
|
|
132
719
|
|
|
133
|
-
// Field selection
|
|
720
|
+
// Field selection. PostgREST select may carry a `col AS alias` rename (#57);
|
|
721
|
+
// the allowlist check uses the underlying column, the SELECT keeps the alias.
|
|
134
722
|
if len(opts.Fields) > 0 {
|
|
135
723
|
var safe []string
|
|
136
724
|
for _, f := range opts.Fields {
|
|
137
|
-
|
|
725
|
+
col := f
|
|
726
|
+
if i := strings.Index(strings.ToUpper(f), " AS "); i != -1 {
|
|
727
|
+
col = strings.TrimSpace(f[:i])
|
|
728
|
+
}
|
|
729
|
+
if allowedFields[col] {
|
|
138
730
|
safe = append(safe, f)
|
|
731
|
+
} else if postgrest {
|
|
732
|
+
return query, 0, 1, queryErr("Unknown column '%s' in select", col)
|
|
139
733
|
}
|
|
140
734
|
}
|
|
141
735
|
if len(safe) > 0 {
|
|
@@ -149,6 +743,15 @@ func ApplyQuery(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (
|
|
|
149
743
|
}
|
|
150
744
|
|
|
151
745
|
// Pagination
|
|
746
|
+
if postgrest {
|
|
747
|
+
// PostgREST: LIMIT + explicit OFFSET row skip, windowing the bare array.
|
|
748
|
+
if opts.Offset > 0 {
|
|
749
|
+
query = query.Offset(opts.Offset)
|
|
750
|
+
}
|
|
751
|
+
query = query.Limit(opts.Limit)
|
|
752
|
+
return query, 0, 1, nil
|
|
753
|
+
}
|
|
754
|
+
|
|
152
755
|
offset := (opts.Page - 1) * opts.Limit
|
|
153
756
|
query = query.Offset(offset).Limit(opts.Limit)
|
|
154
757
|
|
|
@@ -157,7 +760,48 @@ func ApplyQuery(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (
|
|
|
157
760
|
pageCount = 1
|
|
158
761
|
}
|
|
159
762
|
|
|
160
|
-
return query, total, pageCount
|
|
763
|
+
return query, total, pageCount, nil
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// buildGroupExpr turns a PostgREST FilterGroup into a nested GORM expression
|
|
767
|
+
// (a *gorm.DB usable as a Where argument). Unknown columns are a client error
|
|
768
|
+
// (400). Returns nil when the group produced no applicable condition.
|
|
769
|
+
func buildGroupExpr(db *gorm.DB, g FilterGroup, allowedFields map[string]bool) (*gorm.DB, error) {
|
|
770
|
+
useOr := g.Conjunction == "OR"
|
|
771
|
+
tx := db.Session(&gorm.Session{NewDB: true})
|
|
772
|
+
applied := false
|
|
773
|
+
|
|
774
|
+
add := func(cond *gorm.DB) {
|
|
775
|
+
if !applied {
|
|
776
|
+
tx = tx.Where(cond)
|
|
777
|
+
applied = true
|
|
778
|
+
} else if useOr {
|
|
779
|
+
tx = tx.Or(cond)
|
|
780
|
+
} else {
|
|
781
|
+
tx = tx.Where(cond)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
for _, f := range g.Filters {
|
|
786
|
+
if !allowedFields[f.Field] {
|
|
787
|
+
return nil, queryErr("Unknown column '%s' in query", f.Field)
|
|
788
|
+
}
|
|
789
|
+
leaf := applyOperator(db.Session(&gorm.Session{NewDB: true}), f.Field, f.Operator, f.Value, false)
|
|
790
|
+
add(leaf)
|
|
791
|
+
}
|
|
792
|
+
for _, sub := range g.Groups {
|
|
793
|
+
expr, err := buildGroupExpr(db, sub, allowedFields)
|
|
794
|
+
if err != nil {
|
|
795
|
+
return nil, err
|
|
796
|
+
}
|
|
797
|
+
if expr != nil {
|
|
798
|
+
add(expr)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if !applied {
|
|
802
|
+
return nil, nil
|
|
803
|
+
}
|
|
804
|
+
return tx, nil
|
|
161
805
|
}
|
|
162
806
|
|
|
163
807
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +889,16 @@ func applyOperator(query *gorm.DB, field, op, value string, useOr bool) *gorm.DB
|
|
|
245
889
|
return where(fmt.Sprintf("%s LIKE ?", field), "%"+value+"%")
|
|
246
890
|
case "$excl":
|
|
247
891
|
return where(fmt.Sprintf("%s NOT LIKE ?", field), "%"+value+"%")
|
|
892
|
+
case "$like":
|
|
893
|
+
// PostgREST like: the pattern is applied verbatim (wildcards already
|
|
894
|
+
// translated from * to %).
|
|
895
|
+
return where(fmt.Sprintf("%s LIKE ?", field), value)
|
|
896
|
+
case "$ilike":
|
|
897
|
+
return where(fmt.Sprintf("%s ILIKE ?", field), value)
|
|
898
|
+
case "$nlike":
|
|
899
|
+
return where(fmt.Sprintf("%s NOT LIKE ?", field), value)
|
|
900
|
+
case "$nilike":
|
|
901
|
+
return where(fmt.Sprintf("%s NOT ILIKE ?", field), value)
|
|
248
902
|
case "$in":
|
|
249
903
|
return where(fmt.Sprintf("%s IN ?", field), coerceValues(splitValue(value)))
|
|
250
904
|
case "$notin":
|