sera-2 1.23.0__tar.gz → 1.25.0__tar.gz

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.
Files changed (52) hide show
  1. {sera_2-1.23.0 → sera_2-1.25.0}/PKG-INFO +4 -3
  2. {sera_2-1.23.0 → sera_2-1.25.0}/pyproject.toml +2 -2
  3. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/base_service.py +85 -21
  4. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/search_helper.py +4 -2
  5. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/make_app.py +4 -5
  6. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/make_python_model.py +70 -3
  7. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/make_typescript_model.py +8 -347
  8. sera_2-1.25.0/sera/make/ts_frontend/__init__.py +0 -0
  9. sera_2-1.25.0/sera/make/ts_frontend/make_class_schema.py +369 -0
  10. sera_2-1.25.0/sera/make/ts_frontend/make_enums.py +104 -0
  11. sera_2-1.25.0/sera/make/ts_frontend/misc.py +38 -0
  12. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/__init__.py +8 -1
  13. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_class.py +2 -1
  14. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_enum.py +2 -1
  15. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_parse.py +15 -2
  16. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_property.py +8 -0
  17. {sera_2-1.23.0 → sera_2-1.25.0}/sera/typing.py +1 -0
  18. {sera_2-1.23.0 → sera_2-1.25.0}/README.md +0 -0
  19. {sera_2-1.23.0 → sera_2-1.25.0}/sera/__init__.py +0 -0
  20. {sera_2-1.23.0 → sera_2-1.25.0}/sera/constants.py +0 -0
  21. {sera_2-1.23.0 → sera_2-1.25.0}/sera/exports/__init__.py +0 -0
  22. {sera_2-1.23.0 → sera_2-1.25.0}/sera/exports/schema.py +0 -0
  23. {sera_2-1.23.0 → sera_2-1.25.0}/sera/exports/test.py +0 -0
  24. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/__init__.py +0 -0
  25. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/api_helper.py +0 -0
  26. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/api_test_helper.py +0 -0
  27. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/base_orm.py +0 -0
  28. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/__init__.py +0 -0
  29. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_dcg.py +0 -0
  30. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_edge.py +0 -0
  31. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_flow.py +0 -0
  32. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_fn_signature.py +0 -0
  33. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_node.py +0 -0
  34. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_runtime.py +0 -0
  35. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/directed_computing_graph/_type_conversion.py +0 -0
  36. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/middlewares/__init__.py +0 -0
  37. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/middlewares/auth.py +0 -0
  38. {sera_2-1.23.0 → sera_2-1.25.0}/sera/libs/middlewares/uscp.py +0 -0
  39. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/__init__.py +0 -0
  40. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/__main__.py +0 -0
  41. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/make_python_api.py +0 -0
  42. {sera_2-1.23.0 → sera_2-1.25.0}/sera/make/make_python_services.py +0 -0
  43. {sera_2-1.23.0 → sera_2-1.25.0}/sera/misc/__init__.py +0 -0
  44. {sera_2-1.23.0 → sera_2-1.25.0}/sera/misc/_formatter.py +0 -0
  45. {sera_2-1.23.0 → sera_2-1.25.0}/sera/misc/_utils.py +0 -0
  46. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_collection.py +0 -0
  47. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_constraints.py +0 -0
  48. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_datatype.py +0 -0
  49. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_default.py +0 -0
  50. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_module.py +0 -0
  51. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_multi_lingual_string.py +0 -0
  52. {sera_2-1.23.0 → sera_2-1.25.0}/sera/models/_schema.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: sera-2
3
- Version: 1.23.0
3
+ Version: 1.25.0
4
4
  Summary:
5
5
  Author: Binh Vu
6
6
  Author-email: bvu687@gmail.com
@@ -8,8 +8,9 @@ Requires-Python: >=3.12,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.12
10
10
  Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
11
12
  Requires-Dist: black (==25.1.0)
12
- Requires-Dist: codegen-2 (>=2.14.0,<3.0.0)
13
+ Requires-Dist: codegen-2 (>=2.15.1,<3.0.0)
13
14
  Requires-Dist: graph-wrapper (>=1.7.3,<2.0.0)
14
15
  Requires-Dist: isort (==6.0.1)
15
16
  Requires-Dist: litestar (>=2.15.1,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sera-2"
3
- version = "1.23.0"
3
+ version = "1.25.0"
4
4
  description = ""
5
5
  authors = ["Binh Vu <bvu687@gmail.com>"]
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ repository = "https://github.com/binh-vu/sera"
9
9
 
10
10
  [tool.poetry.dependencies]
11
11
  python = "^3.12"
12
- codegen-2 = "^2.14.0"
12
+ codegen-2 = "^2.15.1"
13
13
  msgspec = "^0.19.0"
14
14
  litestar = "^2.15.1"
15
15
  loguru = "^0.7.0"
@@ -10,8 +10,8 @@ from sqlalchemy.orm import contains_eager, load_only
10
10
 
11
11
  from sera.libs.base_orm import BaseORM
12
12
  from sera.libs.search_helper import Query, QueryOp
13
- from sera.misc import assert_not_null, to_snake_case
14
- from sera.models import Cardinality, Class, DataProperty, ObjectProperty
13
+ from sera.misc import assert_isinstance, assert_not_null, to_snake_case
14
+ from sera.models import Cardinality, Class, DataProperty, IndexType, ObjectProperty
15
15
 
16
16
  R = TypeVar("R", bound=BaseORM)
17
17
  ID = TypeVar("ID") # ID of a class
@@ -20,6 +20,7 @@ SqlResult = TypeVar("SqlResult", bound=Result)
20
20
 
21
21
  class QueryResult(NamedTuple, Generic[R]):
22
22
  records: Sequence[R]
23
+ extra_columns: Mapping[str, Sequence]
23
24
  total: Optional[int]
24
25
 
25
26
 
@@ -36,6 +37,7 @@ class BaseAsyncService(Generic[ID, R]):
36
37
  self._cls_id_prop = getattr(self.orm_cls, self.id_prop.name)
37
38
  self.is_id_auto_increment = assert_not_null(self.id_prop.db).is_auto_increment
38
39
 
40
+ # mapping from property name to ORM class for object properties
39
41
  self.prop2orm: dict[str, type] = {
40
42
  prop.name: orm_classes[prop.target.name]
41
43
  for prop in cls.properties.values()
@@ -103,15 +105,15 @@ class BaseAsyncService(Generic[ID, R]):
103
105
  # A -> B is either 1:1 or N:1, we will store the foreign key is in A
104
106
  # .join(B, A.<foreign_key> == B.id)
105
107
  self.join_clauses[prop.name] = [
106
- {
107
- "class": target_tbl,
108
- "condition": getattr(
109
- target_tbl,
110
- assert_not_null(target_cls.get_id_property()).name,
111
- )
112
- == getattr(self.orm_cls, source_fk),
113
- "contains_eager": getattr(self.orm_cls, source_relprop),
114
- },
108
+ # {
109
+ # "class": target_tbl,
110
+ # "condition": getattr(
111
+ # target_tbl,
112
+ # assert_not_null(target_cls.get_id_property()).name,
113
+ # )
114
+ # == getattr(self.orm_cls, source_fk),
115
+ # "contains_eager": getattr(self.orm_cls, source_relprop),
116
+ # },
115
117
  ]
116
118
 
117
119
  @classmethod
@@ -135,6 +137,7 @@ class BaseAsyncService(Generic[ID, R]):
135
137
  session: The database session
136
138
  """
137
139
  q = self._select()
140
+ extra_cols = []
138
141
 
139
142
  if len(query.fields) > 0:
140
143
  q = q.options(
@@ -195,12 +198,60 @@ class BaseAsyncService(Generic[ID, R]):
195
198
  q = q.where(~getattr(self.orm_cls, clause.field).in_(clause.value))
196
199
  else:
197
200
  assert clause.op == QueryOp.fuzzy
198
- # Assuming fuzzy search is implemented as a full-text search
199
- q = q.where(
200
- func.to_tsvector(getattr(self.orm_cls, clause.field)).match(
201
- clause.value
202
- )
201
+ clause_prop = self.cls.properties[clause.field]
202
+ assert (
203
+ isinstance(clause_prop, DataProperty) and clause_prop.db is not None
203
204
  )
205
+ clause_orm_field = getattr(self.orm_cls, clause.field)
206
+ extra_cols.append(f"{clause.field}_score")
207
+
208
+ if clause_prop.db.index_type == IndexType.POSTGRES_FTS_SEVI:
209
+ # fuzzy search is implemented using Postgres Full-Text Search
210
+ # sevi is a custom text search configuration that we defined in `configs/postgres-fts.sql`
211
+ q = q.where(
212
+ func.to_tsvector("sevi", clause_orm_field).bool_op("@@")(
213
+ func.plainto_tsquery("sevi", clause.value)
214
+ )
215
+ )
216
+ # TODO: figure out which rank function is better
217
+ # https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING
218
+ q = q.order_by(
219
+ func.ts_rank_cd(
220
+ func.to_tsvector("sevi", clause_orm_field),
221
+ func.plainto_tsquery("sevi", clause.value),
222
+ ).desc()
223
+ )
224
+ q = q.add_columns(
225
+ func.ts_rank_cd(
226
+ func.to_tsvector("sevi", clause_orm_field),
227
+ func.plainto_tsquery("sevi", clause.value),
228
+ ).label(f"{clause.field}_score")
229
+ )
230
+ elif clause_prop.db.index_type == IndexType.POSTGRES_TRIGRAM:
231
+ # fuzzy search is implemented using Postgres trigram index
232
+ # using a custom function f_unaccent to ignore accents -- see `configs/postgres-fts.sql`
233
+ q = q.where(
234
+ func.f_unaccent(clause_orm_field).bool_op("%>")(
235
+ func.f_unaccent(clause.value)
236
+ )
237
+ )
238
+ q = q.order_by(
239
+ func.f_unaccent(clause_orm_field).op("<->>")(
240
+ func.f_unaccent(clause.value)
241
+ )
242
+ )
243
+ q = q.add_columns(
244
+ (
245
+ 1
246
+ - func.f_unaccent(clause_orm_field).op("<->>")(
247
+ func.f_unaccent(clause.value)
248
+ )
249
+ ).label(f"{clause.field}_score")
250
+ )
251
+ else:
252
+ raise NotImplementedError(
253
+ f"Fuzzy search is not implemented for index type {clause_prop.db.index_type}"
254
+ )
204
255
 
205
256
  for join_condition in query.join_conditions:
206
257
  for join_clause in self.join_clauses[join_condition.prop]:
@@ -211,16 +262,29 @@ class BaseAsyncService(Generic[ID, R]):
211
262
  full=join_condition.join_type == "full",
212
263
  ).options(contains_eager(join_clause["contains_eager"]))
213
264
 
214
- print(">>>", join_clause)
215
-
216
- cq = select(func.count()).select_from(q.subquery())
265
+ # Create count query without order_by clause to improve performance
266
+ cq = select(func.count()).select_from(q.order_by(None).subquery())
217
267
  rq = q.limit(query.limit).offset(query.offset)
218
- records = self._process_result(await session.execute(rq)).scalars().all()
268
+
269
+ if len(extra_cols) == 0:
270
+ records = self._process_result(await session.execute(rq)).scalars().all()
271
+ extra_columns = {}
272
+ else:
273
+ records = []
274
+ raw_extra_columns = [[] for col in extra_cols]
275
+ for row in self._process_result(await session.execute(rq)):
276
+ records.append(row[0])
277
+ for i in range(len(extra_cols)):
278
+ raw_extra_columns[i].append(row[i + 1])
279
+ extra_columns = {
280
+ col: raw_extra_columns[i] for i, col in enumerate(extra_cols)
281
+ }
282
+
219
283
  if query.return_total:
220
284
  total = (await session.execute(cq)).scalar_one()
221
285
  else:
222
286
  total = None
223
- return QueryResult(records, total)
287
+ return QueryResult(records, extra_columns, total)
224
288
 
225
289
  async def get_by_id(self, id: ID, session: AsyncSession) -> Optional[R]:
226
290
  """Retrieving a record by ID."""
@@ -253,9 +253,9 @@ class Query(msgspec.Struct):
253
253
  else:
254
254
  if join_clause.join_type != "inner":
255
255
  output[target_name].extend(
256
- deser_func((val := getattr(record, source_relprop)))
256
+ deser_func(val)
257
257
  for record in result.records
258
- if val is not None
258
+ if (val := getattr(record, source_relprop)) is not None
259
259
  )
260
260
  else:
261
261
  output[target_name].extend(
@@ -265,6 +265,8 @@ class Query(msgspec.Struct):
265
265
 
266
266
  deser_func = dataschema[cls.name].from_db
267
267
  output[cls.name] = [deser_func(record) for record in result.records]
268
+ # include extra columns such as fuzzy search scores
269
+ output.update(result.extra_columns)
268
270
 
269
271
  return output
270
272
 
@@ -7,6 +7,7 @@ from typing import Annotated
7
7
 
8
8
  from codegen.models import DeferredVar, PredefinedFn, Program, expr, stmt
9
9
  from loguru import logger
10
+
10
11
  from sera.make.make_python_api import make_python_api
11
12
  from sera.make.make_python_model import (
12
13
  make_python_data_model,
@@ -14,10 +15,8 @@ from sera.make.make_python_model import (
14
15
  make_python_relational_model,
15
16
  )
16
17
  from sera.make.make_python_services import make_python_service_structure
17
- from sera.make.make_typescript_model import (
18
- make_typescript_data_model,
19
- make_typescript_enum,
20
- )
18
+ from sera.make.make_typescript_model import make_typescript_data_model
19
+ from sera.make.ts_frontend.make_enums import make_typescript_enums
21
20
  from sera.misc import Formatter
22
21
  from sera.models import App, DataCollection, parse_schema
23
22
  from sera.typing import Language
@@ -170,7 +169,7 @@ def make_app(
170
169
  # generate services
171
170
  make_python_service_structure(app, collections)
172
171
  elif language == Language.Typescript:
173
- make_typescript_enum(schema, app.models)
172
+ make_typescript_enums(schema, app.models)
174
173
  make_typescript_data_model(schema, app.models)
175
174
 
176
175
  Formatter.get_instance().process()
@@ -22,6 +22,7 @@ from sera.models import (
22
22
  Cardinality,
23
23
  Class,
24
24
  DataProperty,
25
+ IndexType,
25
26
  ObjectProperty,
26
27
  Package,
27
28
  PyTypeWithDep,
@@ -1207,14 +1208,79 @@ def make_python_relational_model(
1207
1208
  )
1208
1209
 
1209
1210
  index_stmts = []
1210
- if len(cls.db.indices) > 0:
1211
+
1212
+ if len(cls.db.indices) > 0 or any(
1213
+ isinstance(prop, DataProperty)
1214
+ and prop.db is not None
1215
+ and prop.db.is_indexed
1216
+ and (
1217
+ prop.db.index_type == IndexType.POSTGRES_FTS_SEVI
1218
+ or prop.db.index_type == IndexType.POSTGRES_TRIGRAM
1219
+ )
1220
+ for prop in cls.properties.values()
1221
+ ):
1211
1222
  program.import_("sqlalchemy.Index", True)
1223
+
1224
+ fts_index = []
1225
+ for prop in cls.properties.values():
1226
+ if (
1227
+ not isinstance(prop, DataProperty)
1228
+ or prop.db is None
1229
+ or not prop.db.is_indexed
1230
+ ):
1231
+ continue
1232
+ if prop.db.index_type == IndexType.POSTGRES_FTS_SEVI:
1233
+ fts_index.append(
1234
+ expr.ExprFuncCall(
1235
+ expr.ExprIdent("Index"),
1236
+ [
1237
+ expr.ExprConstant(
1238
+ f"ix_{cls.db.table_name}_{get_property_name(prop)}_gin"
1239
+ ),
1240
+ expr.ExprFuncCall(
1241
+ ident_manager.use("text"),
1242
+ [
1243
+ expr.ExprConstant(
1244
+ f"to_tsvector('sevi', {get_property_name(prop)})"
1245
+ )
1246
+ ],
1247
+ ),
1248
+ PredefinedFn.keyword_assignment(
1249
+ "postgresql_using", expr.ExprConstant("gin")
1250
+ ),
1251
+ ],
1252
+ )
1253
+ )
1254
+ if prop.db.index_type == IndexType.POSTGRES_TRIGRAM:
1255
+ fts_index.append(
1256
+ expr.ExprFuncCall(
1257
+ expr.ExprIdent("Index"),
1258
+ [
1259
+ expr.ExprConstant(
1260
+ f"ix_{cls.db.table_name}_{get_property_name(prop)}_gist"
1261
+ ),
1262
+ expr.ExprFuncCall(
1263
+ expr.ExprIdent("text"),
1264
+ [
1265
+ expr.ExprConstant(
1266
+ f"f_unaccent({get_property_name(prop)}) gist_trgm_ops(siglen=256)"
1267
+ )
1268
+ ],
1269
+ ),
1270
+ PredefinedFn.keyword_assignment(
1271
+ "postgresql_using", expr.ExprConstant("gist")
1272
+ ),
1273
+ ],
1274
+ )
1275
+ )
1276
+
1212
1277
  index_stmts.append(
1213
1278
  stmt.DefClassVarStatement(
1214
1279
  "_table_args__",
1215
1280
  None,
1216
1281
  PredefinedFn.tuple(
1217
- [
1282
+ fts_index
1283
+ + [
1218
1284
  expr.ExprFuncCall(
1219
1285
  expr.ExprIdent("Index"),
1220
1286
  [expr.ExprConstant(index.name)]
@@ -1317,7 +1383,8 @@ def make_python_relational_model(
1317
1383
  "unique", expr.ExprConstant(True)
1318
1384
  )
1319
1385
  )
1320
- elif prop.db.is_indexed:
1386
+ elif prop.db.is_indexed and prop.db.index_type == IndexType.DEFAULT:
1387
+ # only add index=True for default index type
1321
1388
  propvalargs.append(
1322
1389
  PredefinedFn.keyword_assignment(
1323
1390
  "index", expr.ExprConstant(True)