@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.
@@ -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
- // ParseQueryParams parses standard query parameters from a url.Values map
22
- // into a QueryOptions struct.
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
- // Count before pagination
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
- countQuery := query.Session(&gorm.Session{})
119
- countQuery.Count(&total)
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
- query = query.Order(fmt.Sprintf("%s %s", s.Field, dir))
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
- if allowedFields[f] {
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":