@apso/cli 0.36.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.
@@ -51,14 +51,48 @@ func (h *<%= it.entityName %>Handler) RegisterRoutes(r *gin.RouterGroup) {
51
51
  // @Param join query string false "Join: relation or relation||field1,field2 (repeatable)"
52
52
  // @Param limit query int false "Page size (max 200)" default(25)
53
53
  // @Param page query int false "Page number" default(1)
54
+ // @Param select query string false "PostgREST projection: col,alias:col"
55
+ // @Param order query string false "PostgREST order: col.asc|desc[.nullsfirst|nullslast]"
56
+ // @Param offset query int false "PostgREST row offset (bare-array response)"
54
57
  // @Success 200 {object} dto.<%= it.entityName %>PaginatedResponse
58
+ // @Failure 400 {object} map[string]string
55
59
  // @Failure 500 {object} map[string]string
56
60
  // @Router /<%= it.kebabPluralName %> [get]
61
+ //
62
+ // Dual-dialect: the nestjsx dialect (filter/sort/join/fields/or) returns the
63
+ // {data,count,total,page,pageCount} envelope; the PostgREST dialect
64
+ // (select/order/col=op.value/or=()/and=()) returns a bare JSON array. The
65
+ // dialect is chosen by the X-Crud-Dialect header or the query-param shape.
57
66
  func (h *<%= it.entityName %>Handler) FindAll(c *gin.Context) {
58
- opts := utils.ParseQueryParams(c.Request.URL.Query())
67
+ opts, err := utils.ParseQuery(c.Request.URL.Query(), c.GetHeader("X-Crud-Dialect"))
68
+ if err != nil {
69
+ // A malformed dialect request (bad header, ambiguous mix, bad operator)
70
+ // is a client error, not a 500 (apso #60).
71
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
72
+ return
73
+ }
74
+
75
+ // PostgREST dialect => bare JSON array (no envelope) — apso #58.
76
+ if opts.Bare {
77
+ rows, err := h.service.FindAllBare(c.Request.Context(), opts)
78
+ if err != nil {
79
+ if qe, ok := utils.AsQueryError(err); ok {
80
+ c.JSON(http.StatusBadRequest, gin.H{"error": qe.Error()})
81
+ return
82
+ }
83
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
84
+ return
85
+ }
86
+ c.JSON(http.StatusOK, rows)
87
+ return
88
+ }
59
89
 
60
90
  result, err := h.service.FindAll(c.Request.Context(), opts)
61
91
  if err != nil {
92
+ if qe, ok := utils.AsQueryError(err); ok {
93
+ c.JSON(http.StatusBadRequest, gin.H{"error": qe.Error()})
94
+ return
95
+ }
62
96
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
63
97
  return
64
98
  }
@@ -39,6 +39,7 @@ func New<%= it.entityName %>Service(db *gorm.DB) *<%= it.entityName %>Service {
39
39
  }
40
40
 
41
41
  // FindAll retrieves a paginated list of <%= it.pluralName %> with filtering, sorting, and joins.
42
+ // Used by the nestjsx dialect ({data,count,total,page,pageCount} envelope).
42
43
  func (s *<%= it.entityName %>Service) FindAll(ctx context.Context, opts utils.QueryOptions) (*dto.<%= it.entityName %>PaginatedResponse, error) {
43
44
  var items []models.<%= it.entityName %>
44
45
 
@@ -47,7 +48,10 @@ func (s *<%= it.entityName %>Service) FindAll(ctx context.Context, opts utils.Qu
47
48
  query = query.Preload("<%= assoc.referenceName || assoc.name %>")
48
49
  <% }) %>
49
50
 
50
- query, total, pageCount := utils.ApplyQuery(query, opts, AllowedFields<%= it.entityName %>)
51
+ query, total, pageCount, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
52
+ if err != nil {
53
+ return nil, err
54
+ }
51
55
 
52
56
  if err := query.Find(&items).Error; err != nil {
53
57
  return nil, err
@@ -67,6 +71,34 @@ func (s *<%= it.entityName %>Service) FindAll(ctx context.Context, opts utils.Qu
67
71
  }, nil
68
72
  }
69
73
 
74
+ // FindAllBare retrieves <%= it.pluralName %> as a bare slice (no pagination
75
+ // envelope), used by the PostgREST dialect (apso #58). An unknown filter/sort/
76
+ // select column surfaces as a *utils.QueryError so the handler answers 400
77
+ // rather than 500 (apso #60).
78
+ func (s *<%= it.entityName %>Service) FindAllBare(ctx context.Context, opts utils.QueryOptions) ([]dto.<%= it.entityName %>Response, error) {
79
+ var items []models.<%= it.entityName %>
80
+
81
+ query := s.db.WithContext(ctx).Model(&models.<%= it.entityName %>{})
82
+ <% it.associations.filter(a => a.eager).forEach((assoc) => { %>
83
+ query = query.Preload("<%= assoc.referenceName || assoc.name %>")
84
+ <% }) %>
85
+
86
+ query, _, _, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
87
+ if err != nil {
88
+ return nil, err
89
+ }
90
+
91
+ if err := query.Find(&items).Error; err != nil {
92
+ return nil, err
93
+ }
94
+
95
+ response := make([]dto.<%= it.entityName %>Response, len(items))
96
+ for i, item := range items {
97
+ response[i] = s.toResponse(&item)
98
+ }
99
+ return response, nil
100
+ }
101
+
70
102
  // FindOne retrieves a single <%= it.entityName %> by ID
71
103
  func (s *<%= it.entityName %>Service) FindOne(ctx context.Context, id string) (*dto.<%= it.entityName %>Response, error) {
72
104
  var item models.<%= it.entityName %>
@@ -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":
@@ -13,6 +13,9 @@ type ParsedFilter struct {
13
13
  type ParsedSort struct {
14
14
  Field string
15
15
  Direction string // "ASC" or "DESC"
16
+ // Nulls is the optional PostgREST null-ordering hint: "" (default),
17
+ // "NULLS FIRST", or "NULLS LAST". Only populated by the PostgREST dialect.
18
+ Nulls string
16
19
  }
17
20
 
18
21
  // ParsedJoin represents a relation to eager-load.
@@ -21,6 +24,28 @@ type ParsedJoin struct {
21
24
  Fields []string // nil means load all fields
22
25
  }
23
26
 
27
+ // FilterGroup is a nested boolean combination of conditions, used by the
28
+ // PostgREST dialect to model ?or=(...) / ?and=(...) logical combinators
29
+ // (apso #56). Conjunction is "OR" or "AND". A branch is either a leaf
30
+ // ParsedFilter or a nested FilterGroup; exactly one is set per branch.
31
+ type FilterGroup struct {
32
+ Conjunction string // "OR" or "AND"
33
+ Filters []ParsedFilter
34
+ Groups []FilterGroup
35
+ }
36
+
37
+ // Dialect identifies which query-param convention a request used.
38
+ type Dialect string
39
+
40
+ const (
41
+ // DialectNestjsx is the incumbent nestjsx/crud dialect
42
+ // (fields/filter/join/sort/s/or/per_page).
43
+ DialectNestjsx Dialect = "nestjsx"
44
+ // DialectPostgrest is the PostgREST/Supabase dialect
45
+ // (select/order, col=op.value, or=()/and=()).
46
+ DialectPostgrest Dialect = "postgrest"
47
+ )
48
+
24
49
  // QueryOptions holds the parsed query parameters for list endpoints.
25
50
  type QueryOptions struct {
26
51
  Filters []ParsedFilter
@@ -30,6 +55,24 @@ type QueryOptions struct {
30
55
  Joins []ParsedJoin
31
56
  Limit int
32
57
  Page int
58
+
59
+ // Dialect is the detected request dialect. Defaults to DialectNestjsx so
60
+ // existing (nestjsx) requests are byte-identical.
61
+ Dialect Dialect
62
+
63
+ // Groups holds PostgREST ?or=()/?and=() logical combinators (apso #56).
64
+ // Each group is ANDed with the flat Filters. Only used by the PostgREST
65
+ // dialect.
66
+ Groups []FilterGroup
67
+
68
+ // Offset is the PostgREST ?offset= row skip. -1 means "not supplied".
69
+ // The nestjsx dialect derives its offset from Page instead.
70
+ Offset int
71
+
72
+ // Bare requests a bare JSON array response (no {data,count,...} envelope).
73
+ // The PostgREST dialect always returns a bare array (apso #58); nestjsx
74
+ // keeps its envelope.
75
+ Bare bool
33
76
  }
34
77
 
35
78
  // PaginatedResult is the standard paginated response envelope.
@@ -44,7 +87,9 @@ type PaginatedResult struct {
44
87
  // DefaultQueryOptions returns options with default pagination values.
45
88
  func DefaultQueryOptions() QueryOptions {
46
89
  return QueryOptions{
47
- Limit: 25,
48
- Page: 1,
90
+ Limit: 25,
91
+ Page: 1,
92
+ Dialect: DialectNestjsx,
93
+ Offset: -1,
49
94
  }
50
95
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.36.0",
9
+ "version": "0.37.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@biomejs/biome": "^1.9.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.36.0",
2
+ "version": "0.37.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",