@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.
@@ -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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.11.1",
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.11.1",
9
+ "version": "0.13.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@electric-sql/pglite": "^0.2.17",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.11.1",
2
+ "version": "0.13.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.11.1",
3
+ "version": "0.13.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",