@apso/cli 0.35.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.
- package/dist/lib/templates/python/routers/router.eta +51 -9
- package/dist/lib/templates/python/services/service.eta +7 -0
- package/dist/lib/templates/python/utils/init.eta +10 -0
- package/dist/lib/templates/python/utils/query.eta +613 -60
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
35
|
+
x_crud_dialect: Optional[str] = Header(default=None, alias="X-Crud-Dialect"),
|
|
36
|
+
):
|
|
34
37
|
"""
|
|
35
|
-
Retrieve
|
|
38
|
+
Retrieve <%= it.pluralEntityName %> under either query dialect.
|
|
36
39
|
|
|
37
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 =
|
|
740
|
+
expr = self._filter_expr(f)
|
|
247
741
|
if expr is not None:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# OR filters
|
|
742
|
+
clauses.append(expr)
|
|
251
743
|
if options.or_filters:
|
|
252
|
-
or_exprs = [
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
300
|
-
count_query = select(func.count()).select_from(self.model)
|
|
301
|
-
for
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apso/cli",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.36.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@biomejs/biome": "^1.9.4",
|
package/oclif.manifest.json
CHANGED