@apso/cli 0.11.1 → 0.12.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,301 @@
1
+ <%~ includeFile('../../header.eta', it) %>
2
+
3
+ package utils
4
+
5
+ import (
6
+ "fmt"
7
+ "math"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ const (
16
+ MaxLimit = 200
17
+ DefaultLimit = 25
18
+ DefaultPage = 1
19
+ )
20
+
21
+ // ParseQueryParams parses standard query parameters from a url.Values map
22
+ // into a QueryOptions struct.
23
+ func ParseQueryParams(params url.Values) QueryOptions {
24
+ opts := DefaultQueryOptions()
25
+
26
+ // Filters: ?filter=field||$op||value (repeatable)
27
+ for _, raw := range params["filter"] {
28
+ if f := parseFilterParam(raw); f != nil {
29
+ opts.Filters = append(opts.Filters, *f)
30
+ }
31
+ }
32
+
33
+ // OR filters: ?or=field||$op||value (repeatable)
34
+ for _, raw := range params["or"] {
35
+ if f := parseFilterParam(raw); f != nil {
36
+ opts.OrFilters = append(opts.OrFilters, *f)
37
+ }
38
+ }
39
+
40
+ // Sorts: ?sort=field,ASC (repeatable)
41
+ for _, raw := range params["sort"] {
42
+ if s := parseSortParam(raw); s != nil {
43
+ opts.Sorts = append(opts.Sorts, *s)
44
+ }
45
+ }
46
+
47
+ // Fields: ?fields=field1,field2
48
+ if fieldsRaw := params.Get("fields"); fieldsRaw != "" {
49
+ for _, f := range strings.Split(fieldsRaw, ",") {
50
+ f = strings.TrimSpace(f)
51
+ if f != "" {
52
+ opts.Fields = append(opts.Fields, f)
53
+ }
54
+ }
55
+ }
56
+
57
+ // Joins: ?join=relation or ?join=relation||field1,field2 (repeatable)
58
+ for _, raw := range params["join"] {
59
+ opts.Joins = append(opts.Joins, parseJoinParam(raw))
60
+ }
61
+
62
+ // Pagination
63
+ if limitRaw := params.Get("limit"); limitRaw != "" {
64
+ if v, err := strconv.Atoi(limitRaw); err == nil {
65
+ if v > MaxLimit {
66
+ v = MaxLimit
67
+ }
68
+ if v > 0 {
69
+ opts.Limit = v
70
+ }
71
+ }
72
+ }
73
+
74
+ if pageRaw := params.Get("page"); pageRaw != "" {
75
+ if v, err := strconv.Atoi(pageRaw); err == nil && v > 0 {
76
+ opts.Page = v
77
+ }
78
+ }
79
+
80
+ return opts
81
+ }
82
+
83
+ // ApplyQuery applies filters, sorts, and pagination from QueryOptions to a
84
+ // GORM query. The allowedFields map restricts which column names can be used
85
+ // in filters and sorts to prevent injection.
86
+ func ApplyQuery(db *gorm.DB, opts QueryOptions, allowedFields map[string]bool) (*gorm.DB, int64, int) {
87
+ query := db
88
+
89
+ // AND filters
90
+ for _, f := range opts.Filters {
91
+ if !allowedFields[f.Field] {
92
+ continue
93
+ }
94
+ query = applyOperator(query, f.Field, f.Operator, f.Value, false)
95
+ }
96
+
97
+ // OR filters
98
+ if len(opts.OrFilters) > 0 {
99
+ query = query.Where(func(tx *gorm.DB) *gorm.DB {
100
+ first := true
101
+ for _, f := range opts.OrFilters {
102
+ if !allowedFields[f.Field] {
103
+ continue
104
+ }
105
+ if first {
106
+ tx = applyOperator(tx, f.Field, f.Operator, f.Value, false)
107
+ first = false
108
+ } else {
109
+ tx = applyOperator(tx, f.Field, f.Operator, f.Value, true)
110
+ }
111
+ }
112
+ return tx
113
+ }(db.Session(&gorm.Session{NewDB: true})))
114
+ }
115
+
116
+ // Count before pagination
117
+ var total int64
118
+ countQuery := query.Session(&gorm.Session{})
119
+ countQuery.Count(&total)
120
+
121
+ // Sorting
122
+ for _, s := range opts.Sorts {
123
+ if !allowedFields[s.Field] {
124
+ continue
125
+ }
126
+ dir := "ASC"
127
+ if strings.EqualFold(s.Direction, "DESC") {
128
+ dir = "DESC"
129
+ }
130
+ query = query.Order(fmt.Sprintf("%s %s", s.Field, dir))
131
+ }
132
+
133
+ // Field selection
134
+ if len(opts.Fields) > 0 {
135
+ var safe []string
136
+ for _, f := range opts.Fields {
137
+ if allowedFields[f] {
138
+ safe = append(safe, f)
139
+ }
140
+ }
141
+ if len(safe) > 0 {
142
+ query = query.Select(safe)
143
+ }
144
+ }
145
+
146
+ // Joins (Preloads)
147
+ for _, j := range opts.Joins {
148
+ query = query.Preload(j.Relation)
149
+ }
150
+
151
+ // Pagination
152
+ offset := (opts.Page - 1) * opts.Limit
153
+ query = query.Offset(offset).Limit(opts.Limit)
154
+
155
+ pageCount := int(math.Ceil(float64(total) / float64(opts.Limit)))
156
+ if pageCount < 1 {
157
+ pageCount = 1
158
+ }
159
+
160
+ return query, total, pageCount
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Internal parsers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ func parseFilterParam(raw string) *ParsedFilter {
168
+ parts := strings.SplitN(raw, "||", 3)
169
+ if len(parts) < 2 {
170
+ return nil
171
+ }
172
+ value := ""
173
+ if len(parts) > 2 {
174
+ value = parts[2]
175
+ }
176
+ return &ParsedFilter{
177
+ Field: parts[0],
178
+ Operator: parts[1],
179
+ Value: value,
180
+ }
181
+ }
182
+
183
+ func parseSortParam(raw string) *ParsedSort {
184
+ parts := strings.SplitN(raw, ",", 2)
185
+ if len(parts) == 0 || parts[0] == "" {
186
+ return nil
187
+ }
188
+ dir := "ASC"
189
+ if len(parts) > 1 {
190
+ d := strings.ToUpper(strings.TrimSpace(parts[1]))
191
+ if d == "DESC" {
192
+ dir = "DESC"
193
+ }
194
+ }
195
+ return &ParsedSort{
196
+ Field: parts[0],
197
+ Direction: dir,
198
+ }
199
+ }
200
+
201
+ func parseJoinParam(raw string) ParsedJoin {
202
+ parts := strings.SplitN(raw, "||", 2)
203
+ j := ParsedJoin{Relation: parts[0]}
204
+ if len(parts) > 1 && parts[1] != "" {
205
+ for _, f := range strings.Split(parts[1], ",") {
206
+ f = strings.TrimSpace(f)
207
+ if f != "" {
208
+ j.Fields = append(j.Fields, f)
209
+ }
210
+ }
211
+ }
212
+ return j
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Operator application
217
+ // ---------------------------------------------------------------------------
218
+
219
+ func applyOperator(query *gorm.DB, field, op, value string, useOr bool) *gorm.DB {
220
+ where := query.Where
221
+ if useOr {
222
+ where = query.Or
223
+ }
224
+
225
+ switch op {
226
+ case "$eq":
227
+ return where(fmt.Sprintf("%s = ?", field), value)
228
+ case "$ne":
229
+ return where(fmt.Sprintf("%s != ?", field), value)
230
+ case "$gt":
231
+ return where(fmt.Sprintf("%s > ?", field), value)
232
+ case "$gte":
233
+ return where(fmt.Sprintf("%s >= ?", field), value)
234
+ case "$lt":
235
+ return where(fmt.Sprintf("%s < ?", field), value)
236
+ case "$lte":
237
+ return where(fmt.Sprintf("%s <= ?", field), value)
238
+ case "$starts":
239
+ return where(fmt.Sprintf("%s LIKE ?", field), value+"%")
240
+ case "$ends":
241
+ return where(fmt.Sprintf("%s LIKE ?", field), "%"+value)
242
+ case "$cont":
243
+ return where(fmt.Sprintf("%s LIKE ?", field), "%"+value+"%")
244
+ case "$excl":
245
+ return where(fmt.Sprintf("%s NOT LIKE ?", field), "%"+value+"%")
246
+ case "$in":
247
+ return where(fmt.Sprintf("%s IN ?", field), splitValue(value))
248
+ case "$notin":
249
+ return where(fmt.Sprintf("%s NOT IN ?", field), splitValue(value))
250
+ case "$isnull":
251
+ return where(fmt.Sprintf("%s IS NULL", field))
252
+ case "$notnull":
253
+ return where(fmt.Sprintf("%s IS NOT NULL", field))
254
+ case "$between":
255
+ vals := splitValue(value)
256
+ if len(vals) >= 2 {
257
+ return where(fmt.Sprintf("%s BETWEEN ? AND ?", field), vals[0], vals[1])
258
+ }
259
+ return query
260
+ case "$eqL":
261
+ return where(fmt.Sprintf("LOWER(%s) = ?", field), strings.ToLower(value))
262
+ case "$neL":
263
+ return where(fmt.Sprintf("LOWER(%s) != ?", field), strings.ToLower(value))
264
+ case "$startsL":
265
+ return where(fmt.Sprintf("LOWER(%s) LIKE ?", field), strings.ToLower(value)+"%")
266
+ case "$endsL":
267
+ return where(fmt.Sprintf("LOWER(%s) LIKE ?", field), "%"+strings.ToLower(value))
268
+ case "$contL":
269
+ return where(fmt.Sprintf("LOWER(%s) LIKE ?", field), "%"+strings.ToLower(value)+"%")
270
+ case "$exclL":
271
+ return where(fmt.Sprintf("LOWER(%s) NOT LIKE ?", field), "%"+strings.ToLower(value)+"%")
272
+ case "$inL":
273
+ vals := splitValue(value)
274
+ lower := make([]string, len(vals))
275
+ for i, v := range vals {
276
+ lower[i] = strings.ToLower(v)
277
+ }
278
+ return where(fmt.Sprintf("LOWER(%s) IN ?", field), lower)
279
+ case "$notinL":
280
+ vals := splitValue(value)
281
+ lower := make([]string, len(vals))
282
+ for i, v := range vals {
283
+ lower[i] = strings.ToLower(v)
284
+ }
285
+ return where(fmt.Sprintf("LOWER(%s) NOT IN ?", field), lower)
286
+ default:
287
+ return query
288
+ }
289
+ }
290
+
291
+ func splitValue(value string) []string {
292
+ parts := strings.Split(value, ",")
293
+ result := make([]string, 0, len(parts))
294
+ for _, p := range parts {
295
+ p = strings.TrimSpace(p)
296
+ if p != "" {
297
+ result = append(result, p)
298
+ }
299
+ }
300
+ return result
301
+ }
@@ -0,0 +1,50 @@
1
+ <%~ includeFile('../../header.eta', it) %>
2
+
3
+ package utils
4
+
5
+ // ParsedFilter represents a single filter condition.
6
+ type ParsedFilter struct {
7
+ Field string
8
+ Operator string
9
+ Value string
10
+ }
11
+
12
+ // ParsedSort represents a single sort directive.
13
+ type ParsedSort struct {
14
+ Field string
15
+ Direction string // "ASC" or "DESC"
16
+ }
17
+
18
+ // ParsedJoin represents a relation to eager-load.
19
+ type ParsedJoin struct {
20
+ Relation string
21
+ Fields []string // nil means load all fields
22
+ }
23
+
24
+ // QueryOptions holds the parsed query parameters for list endpoints.
25
+ type QueryOptions struct {
26
+ Filters []ParsedFilter
27
+ OrFilters []ParsedFilter
28
+ Sorts []ParsedSort
29
+ Fields []string
30
+ Joins []ParsedJoin
31
+ Limit int
32
+ Page int
33
+ }
34
+
35
+ // PaginatedResult is the standard paginated response envelope.
36
+ type PaginatedResult struct {
37
+ Data interface{} `json:"data"`
38
+ Count int `json:"count"`
39
+ Total int64 `json:"total"`
40
+ Page int `json:"page"`
41
+ PageCount int `json:"pageCount"`
42
+ }
43
+
44
+ // DefaultQueryOptions returns options with default pagination values.
45
+ func DefaultQueryOptions() QueryOptions {
46
+ return QueryOptions{
47
+ Limit: 25,
48
+ Page: 1,
49
+ }
50
+ }
@@ -1,7 +1,7 @@
1
1
  <%~ includeFile('../header.eta', it) %>
2
2
 
3
3
  from typing import List, Optional
4
- from fastapi import APIRouter, Depends, HTTPException, Query, status
4
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
6
 
7
7
  from app.database import get_db
@@ -14,6 +14,7 @@ from ..schemas.<%= it.entityName.toLowerCase() %> import (
14
14
  <%= it.entityName %>List,
15
15
  )
16
16
  from ..services.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>Service
17
+ from ..utils.query import parse_query_params
17
18
 
18
19
  router = APIRouter(
19
20
  prefix="/<%= it.pluralEntityName.toLowerCase() %>",
@@ -27,39 +28,30 @@ router = APIRouter(
27
28
  summary="Retrieve multiple <%= it.pluralEntityName %>",
28
29
  )
29
30
  async def get_many(
31
+ request: Request,
30
32
  db: AsyncSession = Depends(get_db),
31
- skip: int = Query(0, ge=0),
32
- limit: int = Query(10, ge=1, le=100),
33
- sort: Optional[str] = Query(None, description="Sort field (prefix with - for descending)"),
34
- <% /* Add filter parameters for common fields */ %>
35
- <% it.associations.filter(a => a.type === 'ManyToOne').forEach((assoc) => { %>
36
- <%= assoc.camelCasedId %>: Optional[<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>str<% } else { %>int<% } %>] = Query(None),
37
- <% }) %>
38
33
  ) -> <%= it.entityName %>List:
39
34
  """
40
35
  Retrieve a paginated list of <%= it.pluralEntityName %>.
36
+
37
+ Supports query parameters:
38
+ - filter=field||$operator||value (repeatable)
39
+ - or=field||$operator||value (repeatable)
40
+ - sort=field,ASC (repeatable)
41
+ - fields=field1,field2
42
+ - join=relation (repeatable)
43
+ - limit=25&page=1
41
44
  """
42
45
  service = <%= it.entityName %>Service(db)
43
-
44
- filters = {}
45
- <% it.associations.filter(a => a.type === 'ManyToOne').forEach((assoc) => { %>
46
- if <%= assoc.camelCasedId %> is not None:
47
- filters["<%= assoc.camelCasedId %>"] = <%= assoc.camelCasedId %>
48
- <% }) %>
49
-
50
- items, total = await service.get_many(
51
- skip=skip,
52
- limit=limit,
53
- sort=sort,
54
- filters=filters,
55
- )
46
+ options = parse_query_params(request.query_params)
47
+ result = await service.get_many(options)
56
48
 
57
49
  return <%= it.entityName %>List(
58
- data=items,
59
- total=total,
60
- page=(skip // limit) + 1 if limit > 0 else 1,
61
- pageSize=limit,
62
- totalPages=(total + limit - 1) // limit if limit > 0 else 1,
50
+ data=result.data,
51
+ count=result.count,
52
+ total=result.total,
53
+ page=result.page,
54
+ pageCount=result.page_count,
63
55
  )
64
56
 
65
57
 
@@ -70,31 +62,19 @@ async def get_many(
70
62
  )
71
63
  async def get_one(
72
64
  id: <% if (it.primaryKeyType === 'uuid') { %>str<% } else { %>int<% } %>,
65
+ request: Request,
73
66
  db: AsyncSession = Depends(get_db),
74
- <% /* Add join parameters */ %>
75
- <% it.associations.forEach((assoc) => { %>
76
- <% if (assoc.type === 'OneToMany' || assoc.type === 'ManyToMany') { %>
77
- join_<%= assoc.pluralizedRelationshipName %>: bool = Query(False, alias="join[<%= assoc.pluralizedRelationshipName %>]"),
78
- <% } else { %>
79
- join_<%= assoc.relationshipName %>: bool = Query(False, alias="join[<%= assoc.relationshipName %>]"),
80
- <% } %>
81
- <% }) %>
82
67
  ) -> <%= it.entityName %>Schema:
83
68
  """
84
69
  Retrieve a single <%= it.entityName %> by ID.
70
+
71
+ Supports query parameters:
72
+ - join=relation (repeatable)
85
73
  """
86
74
  service = <%= it.entityName %>Service(db)
87
75
 
88
- joins = []
89
- <% it.associations.forEach((assoc) => { %>
90
- <% if (assoc.type === 'OneToMany' || assoc.type === 'ManyToMany') { %>
91
- if join_<%= assoc.pluralizedRelationshipName %>:
92
- joins.append("<%= assoc.pluralizedRelationshipName %>")
93
- <% } else { %>
94
- if join_<%= assoc.relationshipName %>:
95
- joins.append("<%= assoc.relationshipName %>")
96
- <% } %>
97
- <% }) %>
76
+ options = parse_query_params(request.query_params)
77
+ joins = [j.relation for j in options.joins] if options.joins else None
98
78
 
99
79
  item = await service.get_one(id, joins=joins)
100
80
  if not item:
@@ -81,7 +81,7 @@ class <%= it.entityName %>(BaseModel):
81
81
  class <%= it.entityName %>List(BaseModel):
82
82
  """Paginated list response for <%= it.entityName %>"""
83
83
  data: List[<%= it.entityName %>]
84
+ count: int
84
85
  total: int
85
86
  page: int
86
- pageSize: int
87
- totalPages: int
87
+ pageCount: int
@@ -1,12 +1,12 @@
1
1
  <%~ includeFile('../header.eta', it) %>
2
2
 
3
- from typing import List, Optional, Tuple, Any, Dict
4
- from sqlalchemy import select, func, desc, asc
3
+ from typing import List, Optional, Any
4
+
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
- from sqlalchemy.orm import selectinload
7
6
 
8
7
  from ..models.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>
9
8
  from ..schemas.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>Create, <%= it.entityName %>Update
9
+ from ..utils.query import QueryBuilder, QueryOptions, PaginatedResult
10
10
 
11
11
 
12
12
  class <%= it.entityName %>Service:
@@ -16,59 +16,13 @@ class <%= it.entityName %>Service:
16
16
 
17
17
  def __init__(self, db: AsyncSession):
18
18
  self.db = db
19
+ self._qb = QueryBuilder(<%= it.entityName %>, db)
19
20
 
20
- async def get_many(
21
- self,
22
- skip: int = 0,
23
- limit: int = 10,
24
- sort: Optional[str] = None,
25
- filters: Optional[Dict[str, Any]] = None,
26
- joins: Optional[List[str]] = None,
27
- ) -> Tuple[List[<%= it.entityName %>], int]:
21
+ async def get_many(self, options: QueryOptions) -> PaginatedResult:
28
22
  """
29
- Retrieve a paginated list of <%= it.pluralEntityName %>.
23
+ Retrieve a paginated list of <%= it.pluralEntityName %> with filtering, sorting, and joins.
30
24
  """
31
- # Build base query
32
- query = select(<%= it.entityName %>)
33
-
34
- # Apply filters
35
- if filters:
36
- for field, value in filters.items():
37
- if hasattr(<%= it.entityName %>, field) and value is not None:
38
- query = query.where(getattr(<%= it.entityName %>, field) == value)
39
-
40
- # Apply sorting
41
- if sort:
42
- if sort.startswith("-"):
43
- query = query.order_by(desc(getattr(<%= it.entityName %>, sort[1:])))
44
- else:
45
- query = query.order_by(asc(getattr(<%= it.entityName %>, sort)))
46
- else:
47
- query = query.order_by(<%= it.entityName %>.id)
48
-
49
- # Apply joins
50
- if joins:
51
- for join in joins:
52
- if hasattr(<%= it.entityName %>, join):
53
- query = query.options(selectinload(getattr(<%= it.entityName %>, join)))
54
-
55
- # Get total count
56
- count_query = select(func.count()).select_from(<%= it.entityName %>)
57
- if filters:
58
- for field, value in filters.items():
59
- if hasattr(<%= it.entityName %>, field) and value is not None:
60
- count_query = count_query.where(getattr(<%= it.entityName %>, field) == value)
61
- total_result = await self.db.execute(count_query)
62
- total = total_result.scalar() or 0
63
-
64
- # Apply pagination
65
- query = query.offset(skip).limit(limit)
66
-
67
- # Execute query
68
- result = await self.db.execute(query)
69
- items = list(result.scalars().all())
70
-
71
- return items, total
25
+ return await self._qb.get_many(options)
72
26
 
73
27
  async def get_one(
74
28
  self,
@@ -78,16 +32,7 @@ class <%= it.entityName %>Service:
78
32
  """
79
33
  Retrieve a single <%= it.entityName %> by ID.
80
34
  """
81
- query = select(<%= it.entityName %>).where(<%= it.entityName %>.id == id)
82
-
83
- # Apply joins
84
- if joins:
85
- for join in joins:
86
- if hasattr(<%= it.entityName %>, join):
87
- query = query.options(selectinload(getattr(<%= it.entityName %>, join)))
88
-
89
- result = await self.db.execute(query)
90
- return result.scalar_one_or_none()
35
+ return await self._qb.get_one(id, joins=joins)
91
36
 
92
37
  async def create_one(self, data: <%= it.entityName %>Create) -> <%= it.entityName %>:
93
38
  """
@@ -0,0 +1,21 @@
1
+ <%~ includeFile('../header.eta', it) %>
2
+
3
+ from .query import (
4
+ parse_query_params,
5
+ QueryBuilder,
6
+ QueryOptions,
7
+ PaginatedResult,
8
+ ParsedFilter,
9
+ ParsedSort,
10
+ ParsedJoin,
11
+ )
12
+
13
+ __all__ = [
14
+ "parse_query_params",
15
+ "QueryBuilder",
16
+ "QueryOptions",
17
+ "PaginatedResult",
18
+ "ParsedFilter",
19
+ "ParsedSort",
20
+ "ParsedJoin",
21
+ ]