sera-2 1.2.1__py3-none-any.whl → 1.4.2__py3-none-any.whl

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.
sera/constants.py ADDED
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ # Constant representing an unassigned integer ID
4
+ NO_INT_ID: int = None # type: ignore
sera/libs/base_orm.py CHANGED
@@ -73,7 +73,7 @@ class ListDataclassType(TypeDecorator):
73
73
  return [self.cls.from_dict(x) for x in result]
74
74
 
75
75
 
76
- class DictDataClassType(TypeDecorator):
76
+ class DictDataclassType(TypeDecorator):
77
77
  """SqlAlchemy Type decorator to serialize mapping of dataclasses"""
78
78
 
79
79
  impl = LargeBinary
sera/libs/base_service.py CHANGED
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from enum import Enum
4
- from typing import Annotated, Any, Generic, Optional, Sequence, TypeVar
4
+ from math import dist
5
+ from typing import Annotated, Any, Generic, NamedTuple, Optional, Sequence, TypeVar
5
6
 
6
7
  from sera.libs.base_orm import BaseORM
7
8
  from sera.misc import assert_not_null
8
9
  from sera.models import Class
9
10
  from sera.typing import FieldName, T, doc
10
- from sqlalchemy import exists, select
11
- from sqlalchemy.orm import Session
11
+ from sqlalchemy import Result, Select, exists, func, select
12
+ from sqlalchemy.orm import Session, load_only
12
13
 
13
14
 
14
15
  class QueryOp(str, Enum):
@@ -31,6 +32,12 @@ Query = Annotated[
31
32
  ]
32
33
  R = TypeVar("R", bound=BaseORM)
33
34
  ID = TypeVar("ID") # ID of a class
35
+ SqlResult = TypeVar("SqlResult", bound=Result)
36
+
37
+
38
+ class QueryResult(NamedTuple, Generic[R]):
39
+ records: Sequence[R]
40
+ total: int
34
41
 
35
42
 
36
43
  class BaseService(Generic[ID, R]):
@@ -52,7 +59,7 @@ class BaseService(Generic[ID, R]):
52
59
  group_by: list[str],
53
60
  fields: list[str],
54
61
  session: Session,
55
- ) -> Sequence[R]:
62
+ ) -> QueryResult[R]:
56
63
  """Retrieving records matched a query.
57
64
 
58
65
  Args:
@@ -62,14 +69,35 @@ class BaseService(Generic[ID, R]):
62
69
  unique: Whether to return unique results only
63
70
  sorted_by: list of field names to sort by, prefix a field with '-' to sort that field in descending order
64
71
  group_by: list of field names to group by
65
- fields: list of field names to include in the results
72
+ fields: list of field names to include in the results -- empty means all fields
66
73
  """
67
- return []
74
+ q = self._select()
75
+ if fields:
76
+ q = q.options(
77
+ load_only(*[getattr(self.orm_cls, field) for field in fields])
78
+ )
79
+ if unique:
80
+ q = q.distinct()
81
+ if sorted_by:
82
+ for field in sorted_by:
83
+ if field.startswith("-"):
84
+ q = q.order_by(getattr(self.orm_cls, field[1:]).desc())
85
+ else:
86
+ q = q.order_by(getattr(self.orm_cls, field))
87
+ if group_by:
88
+ for field in group_by:
89
+ q = q.group_by(getattr(self.orm_cls, field))
90
+
91
+ cq = select(func.count()).select_from(q.subquery())
92
+ rq = q.limit(limit).offset(offset)
93
+ records = self._process_result(session.execute(q)).scalars().all()
94
+ total = session.execute(cq).scalar_one()
95
+ return QueryResult(records, total)
68
96
 
69
97
  def get_by_id(self, id: ID, session: Session) -> Optional[R]:
70
98
  """Retrieving a record by ID."""
71
- q = select(self.orm_cls).where(self._cls_id_prop == id)
72
- result = session.execute(q).scalar_one_or_none()
99
+ q = self._select().where(self._cls_id_prop == id)
100
+ result = self._process_result(session.execute(q)).scalar_one_or_none()
73
101
  return result
74
102
 
75
103
  def has_id(self, id: ID, session: Session) -> bool:
@@ -89,3 +117,11 @@ class BaseService(Generic[ID, R]):
89
117
  session.add(record)
90
118
  session.commit()
91
119
  return record
120
+
121
+ def _select(self) -> Select:
122
+ """Get the select statement for the class."""
123
+ return select(self.orm_cls)
124
+
125
+ def _process_result(self, result: SqlResult) -> SqlResult:
126
+ """Process the result of a query."""
127
+ return result
sera/make/__main__.py CHANGED
@@ -5,6 +5,7 @@ from typing import Annotated
5
5
 
6
6
  import typer
7
7
  from sera.make.make_app import make_app
8
+ from sera.models import Language
8
9
 
9
10
  app = typer.Typer(pretty_exceptions_short=True, pretty_exceptions_enable=False)
10
11
 
@@ -29,10 +30,22 @@ def cli(
29
30
  help="API collections to generate.",
30
31
  ),
31
32
  ],
33
+ language: Annotated[
34
+ Language,
35
+ typer.Option("-l", "--language", help="Language of the generated application"),
36
+ ] = Language.Python,
37
+ referenced_schema: Annotated[
38
+ list[str],
39
+ typer.Option(
40
+ "-rs",
41
+ "--referenced-schema",
42
+ help="Classes in the schema that are defined in different modules, used as references and thus should not be generated.",
43
+ ),
44
+ ] = [],
32
45
  ):
33
46
  """Generate Python model classes from a schema file."""
34
47
  typer.echo(f"Generating application in {app_dir}")
35
- make_app(app_dir, schema_files, api_collections)
48
+ make_app(app_dir, schema_files, api_collections, language, referenced_schema)
36
49
 
37
50
 
38
51
  app()
sera/make/make_app.py CHANGED
@@ -13,6 +13,7 @@ from sera.make.make_python_model import (
13
13
  make_python_relational_model,
14
14
  )
15
15
  from sera.make.make_python_services import make_python_service_structure
16
+ from sera.make.make_typescript_model import make_typescript_data_model
16
17
  from sera.models import App, DataCollection, Language, parse_schema
17
18
 
18
19
 
@@ -62,8 +63,8 @@ def make_config(app: App):
62
63
  expr.ExprIdent('os.environ.get("DB_DEBUG", "0") == "1"'),
63
64
  ),
64
65
  lambda ast: ast.assign(
65
- DeferredVar.simple("ROUTER_DEBUG"),
66
- expr.ExprIdent('os.environ.get("ROUTER_DEBUG", "0") == "1"'),
66
+ DeferredVar.simple("API_DEBUG"),
67
+ expr.ExprIdent('os.environ.get("API_DEBUG", "0") == "1"'),
67
68
  ),
68
69
  stmt.LineBreak(),
69
70
  lambda ast: ast.assign(
@@ -113,25 +114,54 @@ def make_app(
113
114
  "Language of the generated application. Currently only Python is supported."
114
115
  ),
115
116
  ] = Language.Python,
117
+ referenced_schema: Annotated[
118
+ list[str],
119
+ doc(
120
+ "Classes in the schema that are defined in different modules, used as references and thus should not be generated."
121
+ ),
122
+ ] = [],
116
123
  ):
117
124
  schema = parse_schema(schema_files)
118
125
 
119
126
  app = App(app_dir.name, app_dir, schema_files, language)
120
127
 
121
- # generate application configuration
122
- make_config(app)
123
-
124
- # generate models from schema
125
- make_python_data_model(schema, app.models.pkg("data"))
126
- make_python_relational_model(schema, app.models.pkg("db"), app.models.pkg("data"))
127
-
128
- collections = [DataCollection(schema.classes[cname]) for cname in api_collections]
129
-
130
- # generate API
131
- make_python_api(app, collections)
132
-
133
- # generate services
134
- make_python_service_structure(app, collections)
128
+ if language == Language.Python:
129
+ # generate application configuration
130
+ make_config(app)
131
+
132
+ # generate models from schema
133
+ # TODO: fix me, this is a hack to make the code work for referenced classes
134
+ referenced_classes = {
135
+ path.rsplit(".", 1)[1]: (parts := path.rsplit(".", 1))[0]
136
+ + ".data."
137
+ + parts[1]
138
+ for path in referenced_schema
139
+ }
140
+ make_python_data_model(schema, app.models.pkg("data"), referenced_classes)
141
+ referenced_classes = {
142
+ path.rsplit(".", 1)[1]: (parts := path.rsplit(".", 1))[0]
143
+ + ".db."
144
+ + parts[1]
145
+ for path in referenced_schema
146
+ }
147
+ make_python_relational_model(
148
+ schema,
149
+ app.models.pkg("db"),
150
+ app.models.pkg("data"),
151
+ referenced_classes,
152
+ )
153
+
154
+ collections = [
155
+ DataCollection(schema.classes[cname]) for cname in api_collections
156
+ ]
157
+
158
+ # generate API
159
+ make_python_api(app, collections)
160
+
161
+ # generate services
162
+ make_python_service_structure(app, collections)
163
+ elif language == Language.Typescript:
164
+ make_typescript_data_model(schema, app.models)
135
165
 
136
166
  return app
137
167
 
@@ -27,41 +27,43 @@ def make_python_api(app: App, collections: Sequence[DataCollection]):
27
27
  controllers.append(make_python_create_api(collection, route))
28
28
  controllers.append(make_python_update_api(collection, route))
29
29
 
30
- program = Program()
31
- program.import_("__future__.annotations", True)
32
- program.import_("litestar.Router", True)
33
- for get_route, get_route_fn in controllers:
34
- program.import_(get_route.path + "." + get_route_fn, True)
35
-
36
- program.root(
37
- stmt.LineBreak(),
38
- lambda ast: ast.assign(
39
- DeferredVar.simple("router"),
40
- expr.ExprFuncCall(
41
- expr.ExprIdent("Router"),
42
- [
43
- PredefinedFn.keyword_assignment(
44
- "path",
45
- expr.ExprConstant(
46
- f"/{to_snake_case(collection.name).replace('_', '-')}"
30
+ routemod = route.module("route")
31
+ if not routemod.exists():
32
+ program = Program()
33
+ program.import_("__future__.annotations", True)
34
+ program.import_("litestar.Router", True)
35
+ for get_route, get_route_fn in controllers:
36
+ program.import_(get_route.path + "." + get_route_fn, True)
37
+
38
+ program.root(
39
+ stmt.LineBreak(),
40
+ lambda ast: ast.assign(
41
+ DeferredVar.simple("router"),
42
+ expr.ExprFuncCall(
43
+ expr.ExprIdent("Router"),
44
+ [
45
+ PredefinedFn.keyword_assignment(
46
+ "path",
47
+ expr.ExprConstant(
48
+ f"/api/{to_snake_case(collection.name).replace('_', '-')}"
49
+ ),
47
50
  ),
48
- ),
49
- PredefinedFn.keyword_assignment(
50
- "route_handlers",
51
- PredefinedFn.list(
52
- [
53
- expr.ExprIdent(get_route_fn)
54
- for get_route, get_route_fn in controllers
55
- ]
51
+ PredefinedFn.keyword_assignment(
52
+ "route_handlers",
53
+ PredefinedFn.list(
54
+ [
55
+ expr.ExprIdent(get_route_fn)
56
+ for get_route, get_route_fn in controllers
57
+ ]
58
+ ),
56
59
  ),
57
- ),
58
- ],
60
+ ],
61
+ ),
59
62
  ),
60
- ),
61
- )
63
+ )
62
64
 
63
- route.module("route").write(program)
64
- routes.append(route.module("route"))
65
+ routemod.write(program)
66
+ routes.append(routemod)
65
67
 
66
68
  # make the main entry point
67
69
  make_main(app.api, routes)
@@ -153,7 +155,7 @@ def make_python_get_api(
153
155
  program.import_(app.models.db.path + ".base.get_session", True)
154
156
  program.import_("litestar.di.Provide", True)
155
157
  program.import_("sqlalchemy.orm.Session", True)
156
- program.import_(app.config.path + ".ROUTER_DEBUG", True)
158
+ program.import_(app.config.path + ".API_DEBUG", True)
157
159
  program.import_(
158
160
  f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
159
161
  True,
@@ -224,19 +226,19 @@ def make_python_get_api(
224
226
  DeferredVar.simple(
225
227
  "sorted_by",
226
228
  expr.ExprIdent(
227
- "Annotated[list[str], Parameter(description=\"list of field names to sort by, prefix a field with '-' to sort that field in descending order\")]"
229
+ "Annotated[list[str], Parameter(default=tuple(), description=\"list of field names to sort by, prefix a field with '-' to sort that field in descending order\")]"
228
230
  ),
229
231
  ),
230
232
  DeferredVar.simple(
231
233
  "group_by",
232
234
  expr.ExprIdent(
233
- 'Annotated[list[str], Parameter(description="list of field names to group by")]'
235
+ 'Annotated[list[str], Parameter(default=tuple(), description="list of field names to group by")]'
234
236
  ),
235
237
  ),
236
238
  DeferredVar.simple(
237
239
  "fields",
238
240
  expr.ExprIdent(
239
- 'Annotated[list[str], Parameter(description="list of field names to include in the results")]'
241
+ 'Annotated[list[str], Parameter(default=tuple(), description="list of field names to include in the results")]'
240
242
  ),
241
243
  ),
242
244
  DeferredVar.simple(
@@ -252,7 +254,7 @@ def make_python_get_api(
252
254
  expr.ExprIdent("Session"),
253
255
  ),
254
256
  ],
255
- return_type=expr.ExprIdent(f"Sequence[{collection.name}]"),
257
+ return_type=expr.ExprIdent(f"dict"),
256
258
  is_async=True,
257
259
  )(
258
260
  stmt.SingleExprStatement(
@@ -267,7 +269,7 @@ def make_python_get_api(
267
269
  expr.ExprIdent("QUERYABLE_FIELDS"),
268
270
  PredefinedFn.keyword_assignment(
269
271
  "debug",
270
- expr.ExprIdent("ROUTER_DEBUG"),
272
+ expr.ExprIdent("API_DEBUG"),
271
273
  ),
272
274
  ],
273
275
  ),
@@ -303,14 +305,31 @@ def make_python_get_api(
303
305
  ),
304
306
  ),
305
307
  lambda ast13: ast13.return_(
306
- PredefinedFn.map_list(
307
- expr.ExprIdent("result"),
308
- lambda item: expr.ExprFuncCall(
309
- PredefinedFn.attr_getter(
310
- expr.ExprIdent(collection.name), expr.ExprIdent("from_db")
308
+ PredefinedFn.dict(
309
+ [
310
+ (
311
+ PredefinedFn.attr_getter(
312
+ expr.ExprIdent(collection.name),
313
+ expr.ExprIdent("__name__"),
314
+ ),
315
+ PredefinedFn.map_list(
316
+ PredefinedFn.attr_getter(
317
+ expr.ExprIdent("result"), expr.ExprIdent("records")
318
+ ),
319
+ lambda item: expr.ExprMethodCall(
320
+ expr.ExprIdent(collection.name),
321
+ "from_db",
322
+ [item],
323
+ ),
324
+ ),
311
325
  ),
312
- [item],
313
- ),
326
+ (
327
+ expr.ExprConstant("total"),
328
+ PredefinedFn.attr_getter(
329
+ expr.ExprIdent("result"), expr.ExprIdent("total")
330
+ ),
331
+ ),
332
+ ]
314
333
  )
315
334
  ),
316
335
  ),