@apso/cli 0.34.0 → 0.36.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.
@@ -1,7 +1,9 @@
1
1
  <%~ includeFile('../header.eta', it) %>
2
2
 
3
- from typing import List, Optional
4
- from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
3
+ from typing import List, Optional, Union
4
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
5
+ from fastapi.encoders import jsonable_encoder
6
+ from fastapi.responses import JSONResponse
5
7
  from sqlalchemy.ext.asyncio import AsyncSession
6
8
 
7
9
  from app.database import get_db
@@ -14,7 +16,7 @@ from ..schemas.<%= it.entityName.toLowerCase() %> import (
14
16
  <%= it.entityName %>List,
15
17
  )
16
18
  from ..services.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>Service
17
- from ..utils.query import parse_query_params
19
+ from ..utils.query import parse_query_params, QueryParseError
18
20
 
19
21
  router = APIRouter(
20
22
  prefix="/<%= it.pluralEntityName.toLowerCase() %>",
@@ -24,27 +26,67 @@ router = APIRouter(
24
26
 
25
27
  @router.get(
26
28
  "",
27
- response_model=<%= it.entityName %>List,
28
29
  summary="Retrieve multiple <%= it.pluralEntityName %>",
30
+ response_model=Union[<%= it.entityName %>List, List[<%= it.entityName %>Schema]],
29
31
  )
30
32
  async def get_many(
31
33
  request: Request,
32
34
  db: AsyncSession = Depends(get_db),
33
- ) -> <%= it.entityName %>List:
35
+ x_crud_dialect: Optional[str] = Header(default=None, alias="X-Crud-Dialect"),
36
+ ):
34
37
  """
35
- Retrieve a paginated list of <%= it.pluralEntityName %>.
38
+ Retrieve <%= it.pluralEntityName %> under either query dialect.
36
39
 
37
- Supports query parameters:
40
+ Dialect is chosen by the `X-Crud-Dialect` header (postgrest|nestjsx), else
41
+ inferred from the param shape (parity with the TypeScript services).
42
+
43
+ nestjsx (default) — envelope response {data,count,total,page,pageCount}:
38
44
  - filter=field||$operator||value (repeatable)
39
45
  - or=field||$operator||value (repeatable)
40
46
  - sort=field,ASC (repeatable)
41
47
  - fields=field1,field2
42
48
  - join=relation (repeatable)
43
49
  - limit=25&page=1
50
+
51
+ postgrest — bare JSON array response:
52
+ - select=col,alias:col,rel(cols)
53
+ - col=op.value filters (eq/neq/gt/gte/lt/lte/like/ilike/in/is, not.<op>)
54
+ - or=(...) / and=(...) logical groups
55
+ - order=col.asc|desc[.nullsfirst|nullslast]
56
+ - limit=..&offset=.. (windows the bare array)
44
57
  """
45
58
  service = <%= it.entityName %>Service(db)
46
- options = parse_query_params(request.query_params)
47
- result = await service.get_many(options)
59
+ try:
60
+ options = parse_query_params(request.query_params, x_crud_dialect)
61
+ except QueryParseError as exc:
62
+ raise HTTPException(
63
+ status_code=status.HTTP_400_BAD_REQUEST,
64
+ detail=str(exc),
65
+ )
66
+
67
+ if options.dialect == "postgrest":
68
+ # PostgREST always returns a bare JSON array (never the envelope), even
69
+ # when limit/offset are present.
70
+ try:
71
+ rows = await service.get_many_list(options)
72
+ except QueryParseError as exc:
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail=str(exc),
76
+ )
77
+ payload = [
78
+ r if isinstance(r, dict) else <%= it.entityName %>Schema.model_validate(r)
79
+ for r in rows
80
+ ]
81
+ return JSONResponse(content=jsonable_encoder(payload))
82
+
83
+ try:
84
+ result = await service.get_many(options)
85
+ except QueryParseError as exc:
86
+ raise HTTPException(
87
+ status_code=status.HTTP_400_BAD_REQUEST,
88
+ detail=str(exc),
89
+ )
48
90
 
49
91
  return <%= it.entityName %>List(
50
92
  data=result.data,
@@ -21,9 +21,16 @@ class <%= it.entityName %>Service:
21
21
  async def get_many(self, options: QueryOptions) -> PaginatedResult:
22
22
  """
23
23
  Retrieve a paginated list of <%= it.pluralEntityName %> with filtering, sorting, and joins.
24
+ (nestjsx dialect — {data,count,total,page,pageCount} envelope.)
24
25
  """
25
26
  return await self._qb.get_many(options)
26
27
 
28
+ async def get_many_list(self, options: QueryOptions) -> List[Any]:
29
+ """
30
+ Retrieve <%= it.pluralEntityName %> as a bare list (PostgREST dialect).
31
+ """
32
+ return await self._qb.get_many_list(options)
33
+
27
34
  async def get_one(
28
35
  self,
29
36
  id: Any,
@@ -2,8 +2,13 @@
2
2
 
3
3
  from .query import (
4
4
  parse_query_params,
5
+ parse_nestjsx_params,
6
+ parse_postgrest_params,
7
+ detect_dialect,
5
8
  QueryBuilder,
6
9
  QueryOptions,
10
+ QueryGroup,
11
+ QueryParseError,
7
12
  PaginatedResult,
8
13
  ParsedFilter,
9
14
  ParsedSort,
@@ -12,8 +17,13 @@ from .query import (
12
17
 
13
18
  __all__ = [
14
19
  "parse_query_params",
20
+ "parse_nestjsx_params",
21
+ "parse_postgrest_params",
22
+ "detect_dialect",
15
23
  "QueryBuilder",
16
24
  "QueryOptions",
25
+ "QueryGroup",
26
+ "QueryParseError",
17
27
  "PaginatedResult",
18
28
  "ParsedFilter",
19
29
  "ParsedSort",
@@ -6,7 +6,7 @@ import math
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, TypeVar
8
8
 
9
- from sqlalchemy import Boolean, Date, DateTime, Float, Integer, Numeric, asc, desc, func, or_
9
+ from sqlalchemy import Boolean, Date, DateTime, Float, Integer, Numeric, and_, asc, desc, func, not_, or_
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  from sqlalchemy.orm import selectinload
12
12
  from sqlalchemy.sql import Select, select
@@ -18,6 +18,15 @@ DEFAULT_LIMIT = 25
18
18
  DEFAULT_PAGE = 1
19
19
 
20
20
 
21
+ class QueryParseError(Exception):
22
+ """Raised when a query cannot be parsed.
23
+
24
+ The generated router maps this to an HTTP 400 (Bad Request), mirroring the
25
+ PostgREST/@apso/crud contract where a malformed query or an unknown filter
26
+ column is a client error, not a 500 (apsoai #60).
27
+ """
28
+
29
+
21
30
  # ---------------------------------------------------------------------------
22
31
  # Parsed query structures
23
32
  # ---------------------------------------------------------------------------
@@ -33,6 +42,7 @@ class ParsedFilter:
33
42
  class ParsedSort:
34
43
  field: str
35
44
  direction: str # "ASC" or "DESC"
45
+ nulls: Optional[str] = None # "NULLS FIRST" | "NULLS LAST" | None
36
46
 
37
47
 
38
48
  @dataclass
@@ -41,6 +51,18 @@ class ParsedJoin:
41
51
  fields: Optional[List[str]] = None
42
52
 
43
53
 
54
+ @dataclass
55
+ class QueryGroup:
56
+ """A logical group of conditions/nested groups (PostgREST or=()/and=())."""
57
+
58
+ operator: str # "$or" or "$and"
59
+ conditions: List["QueryNode"] = field(default_factory=list)
60
+
61
+
62
+ # A node in the search tree is either a leaf filter or a nested group.
63
+ QueryNode = Any # ParsedFilter | QueryGroup
64
+
65
+
44
66
  @dataclass
45
67
  class QueryOptions:
46
68
  filters: List[ParsedFilter] = field(default_factory=list)
@@ -50,6 +72,17 @@ class QueryOptions:
50
72
  joins: List[ParsedJoin] = field(default_factory=list)
51
73
  limit: int = DEFAULT_LIMIT
52
74
  page: int = DEFAULT_PAGE
75
+ # PostgREST additions. `dialect` selects the response shape and pagination
76
+ # semantics; nestjsx is the incumbent default and is byte-identical to
77
+ # before this field existed.
78
+ dialect: str = "nestjsx" # "nestjsx" | "postgrest"
79
+ offset: Optional[int] = None
80
+ groups: List[QueryGroup] = field(default_factory=list)
81
+ field_aliases: Dict[str, str] = field(default_factory=dict)
82
+ # True when the raw query asked for pagination windowing (limit/offset).
83
+ # Used by the postgrest path to window the bare array.
84
+ limit_present: bool = False
85
+ offset_present: bool = False
53
86
 
54
87
 
55
88
  @dataclass
@@ -112,6 +145,8 @@ def _apply_operator(model: Any, field_name: str, op: str, value: str) -> Any:
112
145
  "$ends": lambda: col.like(f"%{value}"),
113
146
  "$cont": lambda: col.like(f"%{value}%"),
114
147
  "$excl": lambda: ~col.like(f"%{value}%"),
148
+ "$like": lambda: col.like(value),
149
+ "$ilike": lambda: col.ilike(value),
115
150
  "$in": lambda: col.in_(_coerce_list(col, _split_value(value))),
116
151
  "$notin": lambda: ~col.in_(_coerce_list(col, _split_value(value))),
117
152
  "$isnull": lambda: col.is_(None),
@@ -139,7 +174,7 @@ def _split_value(value: str) -> List[str]:
139
174
 
140
175
 
141
176
  # ---------------------------------------------------------------------------
142
- # Parser
177
+ # nestjsx parser (incumbent — unchanged behavior)
143
178
  # ---------------------------------------------------------------------------
144
179
 
145
180
  def _parse_filter_param(raw: str) -> Optional[ParsedFilter]:
@@ -175,12 +210,12 @@ def _parse_join_param(raw: str) -> ParsedJoin:
175
210
  return ParsedJoin(relation=relation, fields=fields)
176
211
 
177
212
 
178
- def parse_query_params(params: Any) -> QueryOptions:
213
+ def parse_nestjsx_params(params: Any) -> QueryOptions:
179
214
  """
180
- Parse Starlette/FastAPI QueryParams into QueryOptions.
215
+ Parse Starlette/FastAPI QueryParams into QueryOptions (nestjsx dialect).
181
216
  Supports repeated keys via getlist().
182
217
  """
183
- opts = QueryOptions()
218
+ opts = QueryOptions(dialect="nestjsx")
184
219
 
185
220
  # Filters
186
221
  for raw in params.getlist("filter"):
@@ -227,6 +262,438 @@ def parse_query_params(params: Any) -> QueryOptions:
227
262
  return opts
228
263
 
229
264
 
265
+ # ---------------------------------------------------------------------------
266
+ # Dialect detection (mirrors @apso/crud dialect.ts)
267
+ # ---------------------------------------------------------------------------
268
+
269
+ # Query keys that only the nestjsx dialect uses.
270
+ _NESTJSX_KEYS = frozenset(["fields", "filter", "or", "join", "sort", "s", "per_page"])
271
+
272
+ # Query keys that only the PostgREST dialect uses.
273
+ _POSTGREST_KEYS = frozenset(["select", "order"])
274
+
275
+ # Keys shared by both dialects — never a signal either way.
276
+ _NEUTRAL_KEYS = frozenset(["limit", "offset", "page", "cache"])
277
+
278
+ # Keys the PostgREST parser reserves (not column filters).
279
+ _POSTGREST_RESERVED = frozenset(["select", "order", "limit", "offset", "and", "or"])
280
+
281
+ # PostgREST operator prefixes. The detector only needs to recognize the SHAPE
282
+ # (`op.` / `not.op.`); unknown operators are rejected by the parser (400).
283
+ _POSTGREST_OPS = frozenset([
284
+ "eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "match", "imatch",
285
+ "in", "is", "isdistinct", "fts", "plfts", "phfts", "wfts", "cs", "cd",
286
+ "ov", "sl", "sr", "nxr", "nxl", "adj",
287
+ ])
288
+
289
+
290
+ def _looks_like_postgrest_value(val: str) -> bool:
291
+ """True when a bare param value is a PostgREST `op.value` / `not.op.value`."""
292
+ if not isinstance(val, str):
293
+ return False
294
+ head = val
295
+ if head.startswith("not."):
296
+ head = head[4:]
297
+ dot = head.find(".")
298
+ if dot == -1:
299
+ return False
300
+ return head[:dot].lower() in _POSTGREST_OPS
301
+
302
+
303
+ def detect_dialect(query_keys_and_values: Any, dialect_header: Optional[str]) -> str:
304
+ """
305
+ Decide which dialect parses this request.
306
+
307
+ Precedence (matches @apso/crud dialect.ts):
308
+ 1. X-Crud-Dialect header (postgrest|nestjsx) always wins; any other
309
+ value is a 400.
310
+ 2. Param-shape: nestjsx-only keys vs PostgREST-only keys / bare
311
+ col=op.value; neutral keys (limit/offset/page/cache) signal neither.
312
+ 3. Both families present => 400 (refuse to guess).
313
+ 4. Only PostgREST signals => postgrest.
314
+ 5. Otherwise (nestjsx signals, neutral-only, or empty) => nestjsx.
315
+
316
+ `query_keys_and_values` is any Starlette QueryParams-like object exposing
317
+ .keys() and .get()/getlist().
318
+ """
319
+ # 1. Explicit header override.
320
+ if dialect_header is not None and str(dialect_header).strip() != "":
321
+ h = str(dialect_header).strip().lower()
322
+ if h in ("postgrest", "nestjsx"):
323
+ return h
324
+ raise QueryParseError(
325
+ f"Invalid X-Crud-Dialect header '{dialect_header}' "
326
+ "(expected 'postgrest' or 'nestjsx')."
327
+ )
328
+
329
+ # 2. Param-shape signals.
330
+ nestjsx = False
331
+ postgrest = False
332
+ for key in _iter_keys(query_keys_and_values):
333
+ if key in _NESTJSX_KEYS:
334
+ nestjsx = True
335
+ elif key in _POSTGREST_KEYS:
336
+ postgrest = True
337
+ elif key in _NEUTRAL_KEYS:
338
+ continue
339
+ else:
340
+ val = _first_value(query_keys_and_values, key)
341
+ if _looks_like_postgrest_value(val):
342
+ postgrest = True
343
+
344
+ # 3. Genuine collision — refuse to guess.
345
+ if nestjsx and postgrest:
346
+ raise QueryParseError(
347
+ "Ambiguous query: it mixes nestjsx params (fields/filter/join/sort/s/or) "
348
+ "with PostgREST params (select/order or col=op.value). Send a single "
349
+ "dialect, or set the 'X-Crud-Dialect' header to force one."
350
+ )
351
+
352
+ # 4/5. One family, or neutral-only/empty (default to nestjsx).
353
+ return "postgrest" if postgrest else "nestjsx"
354
+
355
+
356
+ def _iter_keys(params: Any) -> List[str]:
357
+ try:
358
+ return list(dict.fromkeys(params.keys()))
359
+ except AttributeError:
360
+ return list(params or [])
361
+
362
+
363
+ def _first_value(params: Any, key: str) -> Any:
364
+ getter = getattr(params, "get", None)
365
+ if callable(getter):
366
+ return params.get(key)
367
+ try:
368
+ return params[key]
369
+ except (KeyError, TypeError):
370
+ return None
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # PostgREST parser (mirrors @apso/postgrest-request postgrest-parser.ts)
375
+ # ---------------------------------------------------------------------------
376
+
377
+ # PostgREST operator -> @apso/SQLAlchemy operator. Core set only; cs/cd/ov/fts
378
+ # are deferred (apsoai #54) and rejected clearly.
379
+ _PG_OP_MAP: Dict[str, str] = {
380
+ "eq": "$eq",
381
+ "neq": "$ne",
382
+ "gt": "$gt",
383
+ "gte": "$gte",
384
+ "lt": "$lt",
385
+ "lte": "$lte",
386
+ "like": "$like",
387
+ "ilike": "$ilike",
388
+ "in": "$in",
389
+ }
390
+
391
+ # not.<op> -> the negated operator, where a direct negation exists.
392
+ _PG_NOT_MAP: Dict[str, str] = {
393
+ "eq": "$ne",
394
+ "neq": "$eq",
395
+ "in": "$notin",
396
+ "like": "$excl",
397
+ "ilike": "$exclL",
398
+ }
399
+
400
+ # Operators recognized by PostgREST but deliberately deferred here.
401
+ _PG_DEFERRED_OPS = frozenset([
402
+ "cs", "cd", "ov", "fts", "plfts", "phfts", "wfts", "match", "imatch",
403
+ "isdistinct", "sl", "sr", "nxr", "nxl", "adj",
404
+ ])
405
+
406
+
407
+ def _split_top_level(s: str) -> List[str]:
408
+ """Split on commas that are NOT inside parentheses (nested embeds/groups)."""
409
+ out: List[str] = []
410
+ depth = 0
411
+ cur = ""
412
+ for ch in s:
413
+ if ch == "(":
414
+ depth += 1
415
+ cur += ch
416
+ elif ch == ")":
417
+ depth -= 1
418
+ cur += ch
419
+ elif ch == "," and depth == 0:
420
+ out.append(cur)
421
+ cur = ""
422
+ else:
423
+ cur += ch
424
+ if cur.strip() != "":
425
+ out.append(cur)
426
+ return out
427
+
428
+
429
+ def _parse_pg_select(select_raw: Optional[str], opts: QueryOptions) -> None:
430
+ """
431
+ ?select=col1,alias:col2,rel(col,...)
432
+
433
+ Plain columns become opts.fields; `alias:col` records an output-key rename
434
+ (#57); embeds `rel(...)` become ParsedJoin eager loads. `col::type` casts
435
+ and `!inner`/`!left` join-type hints are stripped (the allowlist governs
436
+ access). `*` selects everything (no explicit field list).
437
+ """
438
+ if not isinstance(select_raw, str) or select_raw.strip() == "":
439
+ return
440
+ fields: List[str] = []
441
+ for raw in _split_top_level(select_raw):
442
+ tok = raw.strip()
443
+ if tok == "":
444
+ continue
445
+ open_i = tok.find("(")
446
+ if open_i == -1:
447
+ no_cast = tok.split("::")[0]
448
+ parts = no_cast.split(":")
449
+ col = parts[-1].strip()
450
+ if col in ("*", ""):
451
+ continue
452
+ fields.append(col)
453
+ if len(parts) > 1:
454
+ alias = parts[0].strip()
455
+ if alias and alias != col:
456
+ opts.field_aliases[col] = alias
457
+ else:
458
+ head = tok[:open_i]
459
+ rel_part = head.split(":")[-1] if ":" in head else head
460
+ rel = rel_part.split("!")[0].strip()
461
+ if not rel:
462
+ raise QueryParseError(f"Invalid embed in select: {tok}")
463
+ # Embedded resources map to eager-loaded relations (selectinload).
464
+ # Embedded column projection / embedded filters are deferred; the
465
+ # embed simply loads the full relation, matching a nestjsx join
466
+ # with no column list.
467
+ opts.joins.append(ParsedJoin(relation=rel, fields=None))
468
+ if fields:
469
+ opts.fields = fields
470
+
471
+
472
+ def _parse_pg_order(order_raw: Optional[str], opts: QueryOptions) -> None:
473
+ """?order=col.asc|desc[.nullsfirst|nullslast],col2..."""
474
+ if order_raw is None:
475
+ return
476
+ for raw in _split_top_level(str(order_raw)):
477
+ t = raw.strip()
478
+ if not t:
479
+ continue
480
+ if "(" in t:
481
+ raise QueryParseError(
482
+ f"Embedded order '{t}' is not supported (embeds are deferred)."
483
+ )
484
+ parts = t.split(".")
485
+ field_name = parts[0]
486
+ if not field_name:
487
+ continue
488
+ direction = "ASC"
489
+ nulls: Optional[str] = None
490
+ for mod in parts[1:]:
491
+ m = mod.lower()
492
+ if m == "asc":
493
+ direction = "ASC"
494
+ elif m == "desc":
495
+ direction = "DESC"
496
+ elif m == "nullsfirst":
497
+ nulls = "NULLS FIRST"
498
+ elif m == "nullslast":
499
+ nulls = "NULLS LAST"
500
+ else:
501
+ raise QueryParseError(f"Invalid order modifier: {mod}")
502
+ opts.sorts.append(ParsedSort(field=field_name, direction=direction, nulls=nulls))
503
+
504
+
505
+ def _parse_in_list(rest: str) -> str:
506
+ """Normalize `in.(1,2,3)` / `in.("a,b",c)` to a comma-joined value string
507
+ consumed by the shared $in operator (which re-splits on comma)."""
508
+ inner = rest.strip()
509
+ if inner.startswith("(") and inner.endswith(")"):
510
+ inner = inner[1:-1]
511
+ if inner == "":
512
+ return ""
513
+ out: List[str] = []
514
+ cur = ""
515
+ quoted = False
516
+ for ch in inner:
517
+ if ch == '"':
518
+ quoted = not quoted
519
+ continue
520
+ if ch == "," and not quoted:
521
+ out.append(cur.strip())
522
+ cur = ""
523
+ continue
524
+ cur += ch
525
+ out.append(cur.strip())
526
+ return ",".join(out)
527
+
528
+
529
+ def _parse_pg_condition(field_name: str, spec: str) -> ParsedFilter:
530
+ """Parse `op.value` (or `not.op.value`) for a column into a ParsedFilter."""
531
+ dot = spec.find(".")
532
+ if dot == -1:
533
+ raise QueryParseError(
534
+ f"Invalid filter for '{field_name}': expected op.value"
535
+ )
536
+ op = spec[:dot]
537
+ rest = spec[dot + 1:]
538
+ negated = False
539
+
540
+ if op == "not":
541
+ negated = True
542
+ dot2 = rest.find(".")
543
+ if dot2 == -1:
544
+ raise QueryParseError(f"Invalid 'not' filter for '{field_name}'")
545
+ op = rest[:dot2]
546
+ rest = rest[dot2 + 1:]
547
+
548
+ if op in _PG_DEFERRED_OPS:
549
+ raise QueryParseError(
550
+ f"PostgREST operator '{op}' is not supported yet "
551
+ f"(deferred: cs/cd/ov/fts and range operators)."
552
+ )
553
+
554
+ # is.null / is.true / is.false (is.unknown => null)
555
+ if op == "is":
556
+ lit = rest.lower()
557
+ if lit in ("null", "unknown"):
558
+ return ParsedFilter(
559
+ field=field_name,
560
+ operator="$notnull" if negated else "$isnull",
561
+ value="",
562
+ )
563
+ if lit in ("true", "false"):
564
+ return ParsedFilter(
565
+ field=field_name,
566
+ operator="$ne" if negated else "$eq",
567
+ value="true" if lit == "true" else "false",
568
+ )
569
+ raise QueryParseError(f"Invalid 'is' value for '{field_name}': {rest}")
570
+
571
+ if op == "in":
572
+ return ParsedFilter(
573
+ field=field_name,
574
+ operator="$notin" if negated else "$in",
575
+ value=_parse_in_list(rest),
576
+ )
577
+
578
+ if op in ("like", "ilike"):
579
+ # PostgREST uses * as the % wildcard.
580
+ pattern = rest.replace("*", "%")
581
+ operator = _PG_NOT_MAP[op] if negated else _PG_OP_MAP[op]
582
+ return ParsedFilter(field=field_name, operator=operator, value=pattern)
583
+
584
+ mapped = _PG_NOT_MAP.get(op) if negated else _PG_OP_MAP.get(op)
585
+ if not mapped:
586
+ raise QueryParseError(
587
+ f"Unsupported PostgREST operator '{op}' for '{field_name}'"
588
+ )
589
+ return ParsedFilter(field=field_name, operator=mapped, value=rest)
590
+
591
+
592
+ def _parse_pg_logical(raw: str, operator: str) -> QueryGroup:
593
+ """?or=(c1,c2) / ?and=(c1,c2), with nested or(...)/and(...) groups (#56)."""
594
+ label = "or" if operator == "$or" else "and"
595
+ trimmed = raw.strip()
596
+ if not (trimmed.startswith("(") and trimmed.endswith(")")):
597
+ raise QueryParseError(
598
+ f"Invalid {label}= group: expected {label}=(cond,cond,...)"
599
+ )
600
+ parts = [p.strip() for p in _split_top_level(trimmed[1:-1]) if p.strip()]
601
+ if not parts:
602
+ raise QueryParseError(f"Empty {label}= group")
603
+ conditions: List[QueryNode] = []
604
+ for part in parts:
605
+ if part.startswith("or("):
606
+ conditions.append(_parse_pg_logical(part[2:], "$or"))
607
+ elif part.startswith("and("):
608
+ conditions.append(_parse_pg_logical(part[3:], "$and"))
609
+ else:
610
+ dot = part.find(".")
611
+ if dot == -1:
612
+ raise QueryParseError(
613
+ f"Invalid condition in {label}= group: {part}"
614
+ )
615
+ conditions.append(
616
+ _parse_pg_condition(part[:dot], part[dot + 1:])
617
+ )
618
+ return QueryGroup(operator=operator, conditions=conditions)
619
+
620
+
621
+ def parse_postgrest_params(params: Any) -> QueryOptions:
622
+ """Parse Starlette/FastAPI QueryParams into QueryOptions (PostgREST dialect)."""
623
+ opts = QueryOptions(dialect="postgrest")
624
+
625
+ _parse_pg_select(_first_value(params, "select"), opts)
626
+ _parse_pg_order(_first_value(params, "order"), opts)
627
+
628
+ # or=()/and=() logical groups (ANDed with the column filters).
629
+ or_raw = _first_value(params, "or")
630
+ if or_raw is not None:
631
+ opts.groups.append(_parse_pg_logical(str(or_raw), "$or"))
632
+ and_raw = _first_value(params, "and")
633
+ if and_raw is not None:
634
+ opts.groups.append(_parse_pg_logical(str(and_raw), "$and"))
635
+
636
+ # Every non-reserved key is a column filter: ?column=op.value
637
+ for key in _iter_keys(params):
638
+ if key in _POSTGREST_RESERVED:
639
+ continue
640
+ for v in _values_for_key(params, key):
641
+ # Embedded filters (`rel.col=op.value`) are deferred: the Python
642
+ # join path uses selectinload (separate SELECT) and cannot push a
643
+ # predicate into a JOIN ON clause. Reject dotted filter columns
644
+ # rather than silently ignoring them.
645
+ if "." in key:
646
+ raise QueryParseError(
647
+ f"Embedded filter '{key}' is not supported yet "
648
+ "(embedded resource filtering is deferred)."
649
+ )
650
+ opts.filters.append(_parse_pg_condition(key, str(v)))
651
+
652
+ # Pagination: limit / offset. PostgREST has no `page`.
653
+ limit_raw = _first_value(params, "limit")
654
+ if limit_raw is not None:
655
+ try:
656
+ opts.limit = min(int(limit_raw), MAX_LIMIT)
657
+ opts.limit_present = True
658
+ except (ValueError, TypeError):
659
+ raise QueryParseError("Invalid limit")
660
+ offset_raw = _first_value(params, "offset")
661
+ if offset_raw is not None:
662
+ try:
663
+ opts.offset = int(offset_raw)
664
+ opts.offset_present = True
665
+ except (ValueError, TypeError):
666
+ raise QueryParseError("Invalid offset")
667
+
668
+ return opts
669
+
670
+
671
+ def _values_for_key(params: Any, key: str) -> List[Any]:
672
+ getlist = getattr(params, "getlist", None)
673
+ if callable(getlist):
674
+ return list(params.getlist(key))
675
+ val = _first_value(params, key)
676
+ return [] if val is None else [val]
677
+
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Unified entrypoint
681
+ # ---------------------------------------------------------------------------
682
+
683
+ def parse_query_params(params: Any, dialect_header: Optional[str] = None) -> QueryOptions:
684
+ """
685
+ Parse query params under the detected dialect.
686
+
687
+ Back-compatible: called with just `params` (no header) and a nestjsx-shaped
688
+ query, it behaves exactly as before. Pass the `X-Crud-Dialect` header to
689
+ force a dialect; otherwise the dialect is detected from the param shape.
690
+ """
691
+ dialect = detect_dialect(params, dialect_header)
692
+ if dialect == "postgrest":
693
+ return parse_postgrest_params(params)
694
+ return parse_nestjsx_params(params)
695
+
696
+
230
697
  # ---------------------------------------------------------------------------
231
698
  # Query builder
232
699
  # ---------------------------------------------------------------------------
@@ -238,83 +705,121 @@ class QueryBuilder:
238
705
  self.model = model
239
706
  self.db = db
240
707
 
241
- async def get_many(self, options: QueryOptions) -> PaginatedResult:
242
- query = select(self.model)
243
-
244
- # AND filters
708
+ # -- shared predicate builders ----------------------------------------
709
+
710
+ def _filter_expr(self, f: ParsedFilter) -> Any:
711
+ col = getattr(self.model, f.field, None)
712
+ if col is None and self._is_postgrest_unknown(f.field):
713
+ # PostgREST answers an unknown column with a 400, not a silent drop
714
+ # (apsoai #60). nestjsx keeps its historical drop-on-unknown.
715
+ raise QueryParseError(f"Unknown column '{f.field}' in query")
716
+ return _apply_operator(self.model, f.field, f.operator, f.value)
717
+
718
+ # Set per get_many call so _filter_expr can decide drop-vs-400.
719
+ _postgrest_mode = False
720
+
721
+ def _is_postgrest_unknown(self, field_name: str) -> bool:
722
+ return self._postgrest_mode and getattr(self.model, field_name, None) is None
723
+
724
+ def _group_expr(self, group: QueryGroup) -> Any:
725
+ exprs = []
726
+ for node in group.conditions:
727
+ if isinstance(node, QueryGroup):
728
+ sub = self._group_expr(node)
729
+ else:
730
+ sub = self._filter_expr(node)
731
+ if sub is not None:
732
+ exprs.append(sub)
733
+ if not exprs:
734
+ return None
735
+ return or_(*exprs) if group.operator == "$or" else and_(*exprs)
736
+
737
+ def _where_clauses(self, options: QueryOptions) -> List[Any]:
738
+ clauses: List[Any] = []
245
739
  for f in options.filters:
246
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
740
+ expr = self._filter_expr(f)
247
741
  if expr is not None:
248
- query = query.where(expr)
249
-
250
- # OR filters
742
+ clauses.append(expr)
251
743
  if options.or_filters:
252
- or_exprs = []
253
- for f in options.or_filters:
254
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
255
- if expr is not None:
256
- or_exprs.append(expr)
744
+ or_exprs = [
745
+ e for e in (self._filter_expr(f) for f in options.or_filters)
746
+ if e is not None
747
+ ]
257
748
  if or_exprs:
258
- query = query.where(or_(*or_exprs))
749
+ clauses.append(or_(*or_exprs))
750
+ for group in options.groups:
751
+ expr = self._group_expr(group)
752
+ if expr is not None:
753
+ clauses.append(expr)
754
+ return clauses
755
+
756
+ def _apply_sorts(self, query: Select, options: QueryOptions) -> Select:
757
+ applied = False
758
+ for s in options.sorts:
759
+ col = getattr(self.model, s.field, None)
760
+ if col is None:
761
+ if self._postgrest_mode:
762
+ raise QueryParseError(f"Unknown column '{s.field}' in query")
763
+ continue
764
+ order_col = desc(col) if s.direction == "DESC" else asc(col)
765
+ if s.nulls == "NULLS FIRST":
766
+ order_col = order_col.nulls_first()
767
+ elif s.nulls == "NULLS LAST":
768
+ order_col = order_col.nulls_last()
769
+ query = query.order_by(order_col)
770
+ applied = True
771
+ if not applied:
772
+ query = query.order_by(self.model.id)
773
+ return query
259
774
 
260
- # Field selection
775
+ def _base_query(self, options: QueryOptions) -> Select:
776
+ # Field selection replaces the column list; otherwise select the model.
261
777
  if options.fields:
262
778
  cols = []
263
779
  for name in options.fields:
264
780
  col = getattr(self.model, name, None)
781
+ if col is None and self._postgrest_mode:
782
+ raise QueryParseError(f"Unknown column '{name}' in query")
265
783
  if col is not None:
266
784
  cols.append(col)
267
785
  if cols:
268
- query = select(*cols).select_from(self.model)
269
- # Re-apply filters on the column-select query
270
- for f in options.filters:
271
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
272
- if expr is not None:
273
- query = query.where(expr)
274
- if options.or_filters:
275
- or_exprs_2 = []
276
- for f in options.or_filters:
277
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
278
- if expr is not None:
279
- or_exprs_2.append(expr)
280
- if or_exprs_2:
281
- query = query.where(or_(*or_exprs_2))
282
-
283
- # Sorting
284
- for s in options.sorts:
285
- col = getattr(self.model, s.field, None)
286
- if col is not None:
287
- query = query.order_by(
288
- desc(col) if s.direction == "DESC" else asc(col)
289
- )
290
- if not options.sorts:
291
- query = query.order_by(self.model.id)
786
+ query: Select = select(*cols).select_from(self.model)
787
+ else:
788
+ query = select(self.model)
789
+ else:
790
+ query = select(self.model)
791
+
792
+ for clause in self._where_clauses(options):
793
+ query = query.where(clause)
794
+
795
+ query = self._apply_sorts(query, options)
292
796
 
293
- # Joins (eager loading)
294
797
  for j in options.joins:
295
798
  rel = getattr(self.model, j.relation, None)
296
799
  if rel is not None:
297
800
  query = query.options(selectinload(rel))
801
+ return query
298
802
 
299
- # Count (before pagination)
300
- count_query = select(func.count()).select_from(self.model)
301
- for f in options.filters:
302
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
303
- if expr is not None:
304
- count_query = count_query.where(expr)
305
- if options.or_filters:
306
- or_exprs_count = []
307
- for f in options.or_filters:
308
- expr = _apply_operator(self.model, f.field, f.operator, f.value)
309
- if expr is not None:
310
- or_exprs_count.append(expr)
311
- if or_exprs_count:
312
- count_query = count_query.where(or_(*or_exprs_count))
803
+ def _count_query(self, options: QueryOptions) -> Select:
804
+ count_query: Select = select(func.count()).select_from(self.model)
805
+ for clause in self._where_clauses(options):
806
+ count_query = count_query.where(clause)
807
+ return count_query
808
+
809
+ # -- public API --------------------------------------------------------
313
810
 
811
+ async def get_many(self, options: QueryOptions) -> PaginatedResult:
812
+ """
813
+ nestjsx dialect: paginated {data,count,total,page,pageCount} envelope.
814
+ Unchanged from the pre-dialect behavior.
815
+ """
816
+ self._postgrest_mode = False
817
+ query = self._base_query(options)
818
+
819
+ count_query = self._count_query(options)
314
820
  total_result = await self.db.execute(count_query)
315
821
  total = total_result.scalar() or 0
316
822
 
317
- # Pagination
318
823
  offset = (options.page - 1) * options.limit
319
824
  query = query.offset(offset).limit(options.limit)
320
825
 
@@ -331,6 +836,54 @@ class QueryBuilder:
331
836
  page_count=page_count,
332
837
  )
333
838
 
839
+ async def get_many_list(self, options: QueryOptions) -> List[Any]:
840
+ """
841
+ PostgREST dialect: return a BARE JSON array (no envelope), even when
842
+ limit/offset are present — limit/offset just window the array (#58).
843
+ An unknown filter/select/order column raises QueryParseError -> 400
844
+ (#60), instead of a silent drop or a 500.
845
+ """
846
+ self._postgrest_mode = True
847
+ try:
848
+ query = self._base_query(options)
849
+
850
+ if options.offset is not None:
851
+ query = query.offset(options.offset)
852
+ if options.limit_present:
853
+ query = query.limit(options.limit)
854
+
855
+ result = await self.db.execute(query)
856
+ items = list(result.scalars().all())
857
+ return self._apply_field_aliases(items, options)
858
+ finally:
859
+ self._postgrest_mode = False
860
+
861
+ def _apply_field_aliases(self, items: List[Any], options: QueryOptions) -> List[Any]:
862
+ """
863
+ PostgREST `select=alias:column` renames the OUTPUT key (#57). When a
864
+ column projection is in play, rows come back as SQLAlchemy Row objects;
865
+ map them to dicts with renamed keys. Without aliases, rows pass through
866
+ unchanged (the router serializes ORM instances as usual).
867
+ """
868
+ aliases = options.field_aliases
869
+ if not aliases:
870
+ return items
871
+ out: List[Any] = []
872
+ for row in items:
873
+ mapping = getattr(row, "_mapping", None)
874
+ if mapping is not None:
875
+ out.append({aliases.get(k, k): v for k, v in mapping.items()})
876
+ elif isinstance(row, dict):
877
+ out.append({aliases.get(k, k): v for k, v in row.items()})
878
+ else:
879
+ # ORM instance: rename attributes onto a plain dict.
880
+ renamed: Dict[str, Any] = {}
881
+ for col, alias in aliases.items():
882
+ if hasattr(row, col):
883
+ renamed[alias] = getattr(row, col)
884
+ out.append(renamed if renamed else row)
885
+ return out
886
+
334
887
  async def get_one(
335
888
  self,
336
889
  id: Any,
@@ -10,10 +10,15 @@ import { ApsorcRelationship, Relationship, RelationshipMap, RelationshipForTempl
10
10
  export declare const parseOneToMany: (relationship: ApsorcRelationship) => RelationshipMap;
11
11
  /**
12
12
  * Parses a ManyToOne relationship definition.
13
- * These are treated as unidirectional from the .apsorc definition.
14
- * Only generates the configuration for the 'from' side.
13
+ * Bidirectional by default, mirroring parseOneToMany with from/to swapped:
14
+ * the target entity gets the inverse OneToMany collection (and with it the
15
+ * controller join allowlist), so `Authors?join=books` works no matter which
16
+ * direction the .apsorc declared the relationship in.
17
+ * Set `bi_directional: false` to keep the old FK-only unidirectional shape.
18
+ * Self-referencing relationships stay unidirectional (both sides would
19
+ * collide on the same RelationshipMap key).
15
20
  * @param relationship The .apsorc relationship definition.
16
- * @returns A RelationshipMap containing only the entry for the 'from' side.
21
+ * @returns A RelationshipMap with entries for both sides (or just 'from' when unidirectional).
17
22
  */
18
23
  export declare const parseManytoOne: (relationship: ApsorcRelationship) => RelationshipMap;
19
24
  /**
@@ -39,20 +39,52 @@ const parseOneToMany = (relationship) => {
39
39
  exports.parseOneToMany = parseOneToMany;
40
40
  /**
41
41
  * Parses a ManyToOne relationship definition.
42
- * These are treated as unidirectional from the .apsorc definition.
43
- * Only generates the configuration for the 'from' side.
42
+ * Bidirectional by default, mirroring parseOneToMany with from/to swapped:
43
+ * the target entity gets the inverse OneToMany collection (and with it the
44
+ * controller join allowlist), so `Authors?join=books` works no matter which
45
+ * direction the .apsorc declared the relationship in.
46
+ * Set `bi_directional: false` to keep the old FK-only unidirectional shape.
47
+ * Self-referencing relationships stay unidirectional (both sides would
48
+ * collide on the same RelationshipMap key).
44
49
  * @param relationship The .apsorc relationship definition.
45
- * @returns A RelationshipMap containing only the entry for the 'from' side.
50
+ * @returns A RelationshipMap with entries for both sides (or just 'from' when unidirectional).
46
51
  */
47
52
  const parseManytoOne = (relationship) => {
53
+ const unidirectional = relationship.bi_directional === false ||
54
+ relationship.from === relationship.to;
55
+ if (unidirectional) {
56
+ return {
57
+ [relationship.from]: [
58
+ {
59
+ name: relationship.to,
60
+ type: "ManyToOne",
61
+ referenceName: relationship.to_name || null,
62
+ nullable: relationship.nullable || false,
63
+ index: relationship.index || false,
64
+ },
65
+ ],
66
+ };
67
+ }
48
68
  return {
49
69
  [relationship.from]: [
50
70
  {
51
71
  name: relationship.to,
52
72
  type: "ManyToOne",
53
- referenceName: relationship.to_name || null,
54
73
  nullable: relationship.nullable || false,
74
+ biDirectional: true,
55
75
  index: relationship.index || false,
76
+ cascadeDelete: relationship.cascadeDelete || false,
77
+ referenceName: relationship.to_name || relationship.to,
78
+ inverseReferenceName: relationship.from,
79
+ },
80
+ ],
81
+ [relationship.to]: [
82
+ {
83
+ name: relationship.from,
84
+ type: "OneToMany",
85
+ biDirectional: true,
86
+ referenceName: relationship.from,
87
+ inverseReferenceName: relationship.to_name || relationship.to,
56
88
  },
57
89
  ],
58
90
  };
@@ -264,18 +296,52 @@ const parseRelationship = (relationship, allRelationships) => {
264
296
  ],
265
297
  };
266
298
  }
267
- case "ManyToOne":
299
+ case "ManyToOne": {
300
+ // Bidirectional by default, mirroring the OneToMany case with the roles
301
+ // swapped: the target entity gets the inverse OneToMany collection (and
302
+ // with it the controller join allowlist), so the collection-side embed
303
+ // works no matter which direction .apsorc declared the relationship in.
304
+ // `bi_directional: false` keeps the old FK-only shape; self-references
305
+ // stay unidirectional (both sides would collide on the same map key).
306
+ const manySideRefName = relationship.to_name || relationship.to;
307
+ if (relationship.bi_directional === false ||
308
+ relationship.from === relationship.to) {
309
+ return {
310
+ [relationship.from]: [
311
+ {
312
+ name: relationship.to,
313
+ type: "ManyToOne",
314
+ referenceName: manySideRefName,
315
+ nullable: relationship.nullable || false,
316
+ index: relationship.index || false,
317
+ },
318
+ ],
319
+ };
320
+ }
268
321
  return {
269
322
  [relationship.from]: [
270
323
  {
271
324
  name: relationship.to,
272
325
  type: "ManyToOne",
273
- referenceName: relationship.to_name || relationship.to,
274
326
  nullable: relationship.nullable || false,
327
+ biDirectional: true,
275
328
  index: relationship.index || false,
329
+ cascadeDelete: relationship.cascadeDelete || false,
330
+ referenceName: manySideRefName,
331
+ inverseReferenceName: relationship.from,
332
+ },
333
+ ],
334
+ [relationship.to]: [
335
+ {
336
+ name: relationship.from,
337
+ type: "OneToMany",
338
+ biDirectional: true,
339
+ referenceName: relationship.from,
340
+ inverseReferenceName: manySideRefName,
276
341
  },
277
342
  ],
278
343
  };
344
+ }
279
345
  case "OneToOne": {
280
346
  const inverseOnetoOneDef = allRelationships.find((def) => def.from === relationship.to &&
281
347
  def.to === relationship.from &&
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.34.0",
9
+ "version": "0.36.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.34.0",
2
+ "version": "0.36.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.34.0",
3
+ "version": "0.36.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",