@apso/cli 0.11.1 → 0.13.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/commands/generate.js +7 -0
- package/dist/commands/migrate.js +26 -32
- package/dist/lib/generators/base.d.ts +5 -0
- package/dist/lib/generators/base.js +7 -0
- package/dist/lib/generators/go.d.ts +2 -1
- package/dist/lib/generators/go.js +60 -4
- package/dist/lib/generators/python.d.ts +2 -1
- package/dist/lib/generators/python.js +48 -4
- package/dist/lib/templates/entities/entity-col-uuid.eta +4 -3
- package/dist/lib/templates/entities/entity.eta +1 -2
- package/dist/lib/templates/go/dto/dto.eta +39 -39
- package/dist/lib/templates/go/handlers/handler.eta +13 -15
- package/dist/lib/templates/go/index-module.eta +2 -2
- package/dist/lib/templates/go/models/model.eta +1 -2
- package/dist/lib/templates/go/services/service.eta +33 -23
- package/dist/lib/templates/go/utils/query.eta +301 -0
- package/dist/lib/templates/go/utils/types.eta +50 -0
- package/dist/lib/templates/python/routers/router.eta +24 -44
- package/dist/lib/templates/python/schemas/schema.eta +2 -2
- package/dist/lib/templates/python/services/service.eta +8 -63
- package/dist/lib/templates/python/utils/init.eta +21 -0
- package/dist/lib/templates/python/utils/query.eta +320 -0
- package/dist/lib/types/generator.d.ts +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta', it) %>
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, TypeVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import asc, desc, func, or_
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
from sqlalchemy.sql import Select, select
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
MAX_LIMIT = 200
|
|
17
|
+
DEFAULT_LIMIT = 25
|
|
18
|
+
DEFAULT_PAGE = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Parsed query structures
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ParsedFilter:
|
|
27
|
+
field: str
|
|
28
|
+
operator: str
|
|
29
|
+
value: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ParsedSort:
|
|
34
|
+
field: str
|
|
35
|
+
direction: str # "ASC" or "DESC"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ParsedJoin:
|
|
40
|
+
relation: str
|
|
41
|
+
fields: Optional[List[str]] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class QueryOptions:
|
|
46
|
+
filters: List[ParsedFilter] = field(default_factory=list)
|
|
47
|
+
or_filters: List[ParsedFilter] = field(default_factory=list)
|
|
48
|
+
sorts: List[ParsedSort] = field(default_factory=list)
|
|
49
|
+
fields: Optional[List[str]] = None
|
|
50
|
+
joins: List[ParsedJoin] = field(default_factory=list)
|
|
51
|
+
limit: int = DEFAULT_LIMIT
|
|
52
|
+
page: int = DEFAULT_PAGE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class PaginatedResult:
|
|
57
|
+
data: List[Any]
|
|
58
|
+
count: int
|
|
59
|
+
total: int
|
|
60
|
+
page: int
|
|
61
|
+
page_count: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Operator registry
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _apply_operator(model: Any, field_name: str, op: str, value: str) -> Any:
|
|
69
|
+
"""Return a SQLAlchemy filter expression for the given operator."""
|
|
70
|
+
col = getattr(model, field_name, None)
|
|
71
|
+
if col is None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
lower_col = func.lower(col)
|
|
75
|
+
|
|
76
|
+
operators: Dict[str, Any] = {
|
|
77
|
+
"$eq": lambda: col == value,
|
|
78
|
+
"$ne": lambda: col != value,
|
|
79
|
+
"$gt": lambda: col > value,
|
|
80
|
+
"$gte": lambda: col >= value,
|
|
81
|
+
"$lt": lambda: col < value,
|
|
82
|
+
"$lte": lambda: col <= value,
|
|
83
|
+
"$starts": lambda: col.like(f"{value}%"),
|
|
84
|
+
"$ends": lambda: col.like(f"%{value}"),
|
|
85
|
+
"$cont": lambda: col.like(f"%{value}%"),
|
|
86
|
+
"$excl": lambda: ~col.like(f"%{value}%"),
|
|
87
|
+
"$in": lambda: col.in_(_split_value(value)),
|
|
88
|
+
"$notin": lambda: ~col.in_(_split_value(value)),
|
|
89
|
+
"$isnull": lambda: col.is_(None),
|
|
90
|
+
"$notnull": lambda: col.isnot(None),
|
|
91
|
+
"$between": lambda: col.between(*_split_value(value)[:2]),
|
|
92
|
+
"$eqL": lambda: lower_col == value.lower(),
|
|
93
|
+
"$neL": lambda: lower_col != value.lower(),
|
|
94
|
+
"$startsL": lambda: lower_col.like(f"{value.lower()}%"),
|
|
95
|
+
"$endsL": lambda: lower_col.like(f"%{value.lower()}"),
|
|
96
|
+
"$contL": lambda: lower_col.like(f"%{value.lower()}%"),
|
|
97
|
+
"$exclL": lambda: ~lower_col.like(f"%{value.lower()}%"),
|
|
98
|
+
"$inL": lambda: lower_col.in_([v.lower() for v in _split_value(value)]),
|
|
99
|
+
"$notinL": lambda: ~lower_col.in_([v.lower() for v in _split_value(value)]),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fn = operators.get(op)
|
|
103
|
+
if fn is None:
|
|
104
|
+
return None
|
|
105
|
+
return fn()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split_value(value: str) -> List[str]:
|
|
109
|
+
"""Split a comma-separated value string."""
|
|
110
|
+
return [v.strip() for v in value.split(",")]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Parser
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _parse_filter_param(raw: str) -> Optional[ParsedFilter]:
|
|
118
|
+
"""Parse 'field||$operator||value' into a ParsedFilter."""
|
|
119
|
+
parts = raw.split("||")
|
|
120
|
+
if len(parts) < 2:
|
|
121
|
+
return None
|
|
122
|
+
field_name = parts[0]
|
|
123
|
+
operator = parts[1]
|
|
124
|
+
value = parts[2] if len(parts) > 2 else ""
|
|
125
|
+
return ParsedFilter(field=field_name, operator=operator, value=value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_sort_param(raw: str) -> Optional[ParsedSort]:
|
|
129
|
+
"""Parse 'field,ASC' or 'field,DESC' into a ParsedSort."""
|
|
130
|
+
parts = raw.split(",")
|
|
131
|
+
if not parts:
|
|
132
|
+
return None
|
|
133
|
+
field_name = parts[0]
|
|
134
|
+
direction = parts[1].upper() if len(parts) > 1 else "ASC"
|
|
135
|
+
if direction not in ("ASC", "DESC"):
|
|
136
|
+
direction = "ASC"
|
|
137
|
+
return ParsedSort(field=field_name, direction=direction)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_join_param(raw: str) -> ParsedJoin:
|
|
141
|
+
"""Parse 'relation' or 'relation||field1,field2' into a ParsedJoin."""
|
|
142
|
+
parts = raw.split("||")
|
|
143
|
+
relation = parts[0]
|
|
144
|
+
fields = None
|
|
145
|
+
if len(parts) > 1 and parts[1]:
|
|
146
|
+
fields = [f.strip() for f in parts[1].split(",")]
|
|
147
|
+
return ParsedJoin(relation=relation, fields=fields)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_query_params(params: Any) -> QueryOptions:
|
|
151
|
+
"""
|
|
152
|
+
Parse Starlette/FastAPI QueryParams into QueryOptions.
|
|
153
|
+
Supports repeated keys via getlist().
|
|
154
|
+
"""
|
|
155
|
+
opts = QueryOptions()
|
|
156
|
+
|
|
157
|
+
# Filters
|
|
158
|
+
for raw in params.getlist("filter"):
|
|
159
|
+
parsed = _parse_filter_param(raw)
|
|
160
|
+
if parsed:
|
|
161
|
+
opts.filters.append(parsed)
|
|
162
|
+
|
|
163
|
+
# OR filters
|
|
164
|
+
for raw in params.getlist("or"):
|
|
165
|
+
parsed = _parse_filter_param(raw)
|
|
166
|
+
if parsed:
|
|
167
|
+
opts.or_filters.append(parsed)
|
|
168
|
+
|
|
169
|
+
# Sorts
|
|
170
|
+
for raw in params.getlist("sort"):
|
|
171
|
+
parsed = _parse_sort_param(raw)
|
|
172
|
+
if parsed:
|
|
173
|
+
opts.sorts.append(parsed)
|
|
174
|
+
|
|
175
|
+
# Fields
|
|
176
|
+
fields_raw = params.get("fields")
|
|
177
|
+
if fields_raw:
|
|
178
|
+
opts.fields = [f.strip() for f in fields_raw.split(",")]
|
|
179
|
+
|
|
180
|
+
# Joins
|
|
181
|
+
for raw in params.getlist("join"):
|
|
182
|
+
opts.joins.append(_parse_join_param(raw))
|
|
183
|
+
|
|
184
|
+
# Pagination
|
|
185
|
+
limit_raw = params.get("limit")
|
|
186
|
+
if limit_raw is not None:
|
|
187
|
+
try:
|
|
188
|
+
opts.limit = min(int(limit_raw), MAX_LIMIT)
|
|
189
|
+
except (ValueError, TypeError):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
page_raw = params.get("page")
|
|
193
|
+
if page_raw is not None:
|
|
194
|
+
try:
|
|
195
|
+
opts.page = max(int(page_raw), 1)
|
|
196
|
+
except (ValueError, TypeError):
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
return opts
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Query builder
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
class QueryBuilder:
|
|
207
|
+
"""Build and execute filtered/sorted/paginated SQLAlchemy queries."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, model: Type[T], db: AsyncSession):
|
|
210
|
+
self.model = model
|
|
211
|
+
self.db = db
|
|
212
|
+
|
|
213
|
+
async def get_many(self, options: QueryOptions) -> PaginatedResult:
|
|
214
|
+
query = select(self.model)
|
|
215
|
+
|
|
216
|
+
# AND filters
|
|
217
|
+
for f in options.filters:
|
|
218
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
219
|
+
if expr is not None:
|
|
220
|
+
query = query.where(expr)
|
|
221
|
+
|
|
222
|
+
# OR filters
|
|
223
|
+
if options.or_filters:
|
|
224
|
+
or_exprs = []
|
|
225
|
+
for f in options.or_filters:
|
|
226
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
227
|
+
if expr is not None:
|
|
228
|
+
or_exprs.append(expr)
|
|
229
|
+
if or_exprs:
|
|
230
|
+
query = query.where(or_(*or_exprs))
|
|
231
|
+
|
|
232
|
+
# Field selection
|
|
233
|
+
if options.fields:
|
|
234
|
+
cols = []
|
|
235
|
+
for name in options.fields:
|
|
236
|
+
col = getattr(self.model, name, None)
|
|
237
|
+
if col is not None:
|
|
238
|
+
cols.append(col)
|
|
239
|
+
if cols:
|
|
240
|
+
query = select(*cols).select_from(self.model)
|
|
241
|
+
# Re-apply filters on the column-select query
|
|
242
|
+
for f in options.filters:
|
|
243
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
244
|
+
if expr is not None:
|
|
245
|
+
query = query.where(expr)
|
|
246
|
+
if options.or_filters:
|
|
247
|
+
or_exprs_2 = []
|
|
248
|
+
for f in options.or_filters:
|
|
249
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
250
|
+
if expr is not None:
|
|
251
|
+
or_exprs_2.append(expr)
|
|
252
|
+
if or_exprs_2:
|
|
253
|
+
query = query.where(or_(*or_exprs_2))
|
|
254
|
+
|
|
255
|
+
# Sorting
|
|
256
|
+
for s in options.sorts:
|
|
257
|
+
col = getattr(self.model, s.field, None)
|
|
258
|
+
if col is not None:
|
|
259
|
+
query = query.order_by(
|
|
260
|
+
desc(col) if s.direction == "DESC" else asc(col)
|
|
261
|
+
)
|
|
262
|
+
if not options.sorts:
|
|
263
|
+
query = query.order_by(self.model.id)
|
|
264
|
+
|
|
265
|
+
# Joins (eager loading)
|
|
266
|
+
for j in options.joins:
|
|
267
|
+
rel = getattr(self.model, j.relation, None)
|
|
268
|
+
if rel is not None:
|
|
269
|
+
query = query.options(selectinload(rel))
|
|
270
|
+
|
|
271
|
+
# Count (before pagination)
|
|
272
|
+
count_query = select(func.count()).select_from(self.model)
|
|
273
|
+
for f in options.filters:
|
|
274
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
275
|
+
if expr is not None:
|
|
276
|
+
count_query = count_query.where(expr)
|
|
277
|
+
if options.or_filters:
|
|
278
|
+
or_exprs_count = []
|
|
279
|
+
for f in options.or_filters:
|
|
280
|
+
expr = _apply_operator(self.model, f.field, f.operator, f.value)
|
|
281
|
+
if expr is not None:
|
|
282
|
+
or_exprs_count.append(expr)
|
|
283
|
+
if or_exprs_count:
|
|
284
|
+
count_query = count_query.where(or_(*or_exprs_count))
|
|
285
|
+
|
|
286
|
+
total_result = await self.db.execute(count_query)
|
|
287
|
+
total = total_result.scalar() or 0
|
|
288
|
+
|
|
289
|
+
# Pagination
|
|
290
|
+
offset = (options.page - 1) * options.limit
|
|
291
|
+
query = query.offset(offset).limit(options.limit)
|
|
292
|
+
|
|
293
|
+
result = await self.db.execute(query)
|
|
294
|
+
items = list(result.scalars().all())
|
|
295
|
+
|
|
296
|
+
page_count = math.ceil(total / options.limit) if options.limit > 0 else 1
|
|
297
|
+
|
|
298
|
+
return PaginatedResult(
|
|
299
|
+
data=items,
|
|
300
|
+
count=len(items),
|
|
301
|
+
total=total,
|
|
302
|
+
page=options.page,
|
|
303
|
+
page_count=page_count,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def get_one(
|
|
307
|
+
self,
|
|
308
|
+
id: Any,
|
|
309
|
+
joins: Optional[List[str]] = None,
|
|
310
|
+
) -> Optional[Any]:
|
|
311
|
+
query = select(self.model).where(self.model.id == id)
|
|
312
|
+
|
|
313
|
+
if joins:
|
|
314
|
+
for join_name in joins:
|
|
315
|
+
rel = getattr(self.model, join_name, None)
|
|
316
|
+
if rel is not None:
|
|
317
|
+
query = query.options(selectinload(rel))
|
|
318
|
+
|
|
319
|
+
result = await self.db.execute(query)
|
|
320
|
+
return result.scalar_one_or_none()
|
|
@@ -173,6 +173,14 @@ export interface MigrationGenerationOptions {
|
|
|
173
173
|
* Migration name/version
|
|
174
174
|
*/
|
|
175
175
|
migrationName?: string;
|
|
176
|
+
/**
|
|
177
|
+
* SQL statements for the up direction (from PGlite sandbox)
|
|
178
|
+
*/
|
|
179
|
+
upSql?: string[];
|
|
180
|
+
/**
|
|
181
|
+
* SQL statements for the down direction (from PGlite sandbox)
|
|
182
|
+
*/
|
|
183
|
+
downSql?: string[];
|
|
176
184
|
}
|
|
177
185
|
/**
|
|
178
186
|
* Interface that all language-specific generators must implement
|
|
@@ -226,6 +234,10 @@ export interface LanguageGenerator {
|
|
|
226
234
|
* Generate guard files (auth, scope, etc.)
|
|
227
235
|
*/
|
|
228
236
|
generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
|
|
237
|
+
/**
|
|
238
|
+
* Generate shared query utility files (filter, sort, pagination)
|
|
239
|
+
*/
|
|
240
|
+
generateQueryUtils(entities: Entity[], apiType: string): Promise<GeneratedFile[]>;
|
|
229
241
|
}
|
|
230
242
|
/**
|
|
231
243
|
* Factory function type for creating language generators
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apso/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@electric-sql/pglite": "^0.2.17",
|
package/oclif.manifest.json
CHANGED