sera-2 1.23.0__py3-none-any.whl → 1.25.0__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/libs/base_service.py +85 -21
- sera/libs/search_helper.py +4 -2
- sera/make/make_app.py +4 -5
- sera/make/make_python_model.py +70 -3
- sera/make/make_typescript_model.py +8 -347
- sera/make/ts_frontend/__init__.py +0 -0
- sera/make/ts_frontend/make_class_schema.py +369 -0
- sera/make/ts_frontend/make_enums.py +104 -0
- sera/make/ts_frontend/misc.py +38 -0
- sera/models/__init__.py +8 -1
- sera/models/_class.py +2 -1
- sera/models/_enum.py +2 -1
- sera/models/_parse.py +15 -2
- sera/models/_property.py +8 -0
- sera/typing.py +1 -0
- {sera_2-1.23.0.dist-info → sera_2-1.25.0.dist-info}/METADATA +4 -3
- {sera_2-1.23.0.dist-info → sera_2-1.25.0.dist-info}/RECORD +18 -14
- {sera_2-1.23.0.dist-info → sera_2-1.25.0.dist-info}/WHEEL +1 -1
sera/libs/base_service.py
CHANGED
@@ -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
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
-
|
199
|
-
|
200
|
-
|
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
|
-
|
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
|
-
|
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."""
|
sera/libs/search_helper.py
CHANGED
@@ -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(
|
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
|
|
sera/make/make_app.py
CHANGED
@@ -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
|
-
|
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
|
-
|
172
|
+
make_typescript_enums(schema, app.models)
|
174
173
|
make_typescript_data_model(schema, app.models)
|
175
174
|
|
176
175
|
Formatter.get_instance().process()
|
sera/make/make_python_model.py
CHANGED
@@ -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
|
-
|
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)
|