sera-2 1.13.0__py3-none-any.whl → 1.14.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/exports/schema.py +146 -120
- sera/libs/api_test_helper.py +43 -0
- sera/make/make_python_api.py +17 -16
- sera/make/make_python_model.py +356 -108
- sera/models/_parse.py +62 -0
- sera/models/_property.py +40 -0
- sera/typing.py +8 -0
- {sera_2-1.13.0.dist-info → sera_2-1.14.0.dist-info}/METADATA +2 -2
- {sera_2-1.13.0.dist-info → sera_2-1.14.0.dist-info}/RECORD +10 -9
- {sera_2-1.13.0.dist-info → sera_2-1.14.0.dist-info}/WHEEL +0 -0
sera/exports/schema.py
CHANGED
@@ -1,128 +1,154 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
from typing import Annotated
|
3
3
|
|
4
|
+
import orjson
|
4
5
|
import typer
|
5
6
|
|
6
|
-
from sera.
|
7
|
-
from sera.models
|
8
|
-
|
9
|
-
|
10
|
-
def
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
return "Boolean"
|
20
|
-
if pytype == "bytes":
|
21
|
-
return "Bytes"
|
22
|
-
if pytype == "dict":
|
23
|
-
return "Json"
|
24
|
-
if pytype == "datetime":
|
25
|
-
return "DateTime"
|
26
|
-
if pytype == "list[str]":
|
27
|
-
return "String[]"
|
28
|
-
if pytype == "list[int]":
|
29
|
-
return "Int[]"
|
30
|
-
if pytype == "list[float]":
|
31
|
-
return "Float[]"
|
32
|
-
if pytype == "list[bool]":
|
33
|
-
return "Boolean[]"
|
34
|
-
if pytype == "list[bytes]":
|
35
|
-
return "Bytes[]"
|
36
|
-
if pytype == "list[dict]":
|
37
|
-
return "Json[]"
|
38
|
-
if pytype == "list[datetime]":
|
39
|
-
return "DateTime[]"
|
40
|
-
|
41
|
-
raise ValueError(f"Unsupported data type for Prisma: {pytype}")
|
42
|
-
|
43
|
-
|
44
|
-
def to_prisma_model(schema: Schema, cls: Class, lines: list[str]):
|
45
|
-
"""Convert a Sera Class to a Prisma model string representation."""
|
46
|
-
lines.append(f"model {cls.name} {{")
|
47
|
-
|
48
|
-
if cls.db is None:
|
49
|
-
# This class has no database mapping, we must generate a default key for it
|
50
|
-
lines.append(
|
51
|
-
f" {'id'.ljust(30)} {'Int'.ljust(10)} @id @default(autoincrement())"
|
52
|
-
)
|
53
|
-
# lines.append(f" @@unique([%s])" % ", ".join(cls.properties.keys()))
|
54
|
-
|
55
|
-
for prop in cls.properties.values():
|
56
|
-
propattrs = ""
|
57
|
-
if isinstance(prop, DataProperty):
|
58
|
-
proptype = get_prisma_field_type(prop.datatype)
|
59
|
-
if prop.is_optional:
|
60
|
-
proptype = f"{proptype}?"
|
61
|
-
if prop.db is not None and prop.db.is_primary_key:
|
62
|
-
propattrs += "@id "
|
63
|
-
|
64
|
-
lines.append(f" {prop.name.ljust(30)} {proptype.ljust(10)} {propattrs}")
|
65
|
-
continue
|
66
|
-
|
67
|
-
if prop.cardinality == Cardinality.MANY_TO_MANY:
|
68
|
-
# For many-to-many relationships, we need to handle the join table
|
69
|
-
lines.append(
|
70
|
-
f" {prop.name.ljust(30)} {(prop.target.name + '[]').ljust(10)}"
|
71
|
-
)
|
72
|
-
else:
|
73
|
-
lines.append(
|
74
|
-
f" {(prop.name + '_').ljust(30)} {prop.target.name.ljust(10)} @relation(fields: [{prop.name}], references: [id])"
|
75
|
-
)
|
76
|
-
lines.append(f" {prop.name.ljust(30)} {'Int'.ljust(10)} @unique")
|
77
|
-
|
78
|
-
lines.append("")
|
79
|
-
for upstream_cls, reverse_upstream_prop in schema.get_upstream_classes(cls):
|
80
|
-
if (
|
81
|
-
reverse_upstream_prop.cardinality == Cardinality.MANY_TO_ONE
|
82
|
-
or reverse_upstream_prop.cardinality == Cardinality.MANY_TO_MANY
|
83
|
-
):
|
84
|
-
|
85
|
-
proptype = f"{upstream_cls.name}[]"
|
86
|
-
else:
|
87
|
-
proptype = upstream_cls.name + "?"
|
88
|
-
lines.append(f" {upstream_cls.name.lower().ljust(30)} {proptype.ljust(10)}")
|
89
|
-
|
90
|
-
lines.append("}\n")
|
91
|
-
|
92
|
-
|
93
|
-
def export_prisma_schema(schema: Schema, outfile: Path):
|
94
|
-
"""Export Prisma schema file"""
|
95
|
-
lines = []
|
96
|
-
|
97
|
-
# Datasource
|
98
|
-
lines.append("datasource db {")
|
99
|
-
lines.append(
|
100
|
-
' provider = "postgresql"'
|
101
|
-
) # Defaulting to postgresql as per user context
|
102
|
-
lines.append(' url = env("DATABASE_URL")')
|
103
|
-
lines.append("}\n")
|
104
|
-
|
105
|
-
# Generator
|
106
|
-
lines.append("generator client {")
|
107
|
-
lines.append(' provider = "prisma-client-py"')
|
108
|
-
lines.append(" recursive_type_depth = 5")
|
109
|
-
lines.append("}\n")
|
110
|
-
|
111
|
-
# Enums
|
112
|
-
if schema.enums:
|
113
|
-
for enum_name, enum_def in schema.enums.items():
|
114
|
-
lines.append(f"enum {enum_name} {{")
|
115
|
-
# Assuming enum_def.values is a list of strings based on previous errors
|
116
|
-
for val_str in enum_def.values:
|
117
|
-
lines.append(f" {val_str}")
|
118
|
-
lines.append("}\\n")
|
119
|
-
|
120
|
-
# Models
|
7
|
+
from sera.misc import assert_not_null, to_snake_case
|
8
|
+
from sera.models import Cardinality, DataProperty, Schema, parse_schema
|
9
|
+
|
10
|
+
|
11
|
+
def export_tbls(schema: Schema, outfile: Path):
|
12
|
+
out = {
|
13
|
+
"name": schema.name,
|
14
|
+
"tables": [],
|
15
|
+
"relations": [],
|
16
|
+
}
|
17
|
+
|
18
|
+
DUMMY_IDPROP = "dkey"
|
19
|
+
|
121
20
|
for cls in schema.topological_sort():
|
122
|
-
|
21
|
+
table = {
|
22
|
+
"name": cls.name,
|
23
|
+
"type": "BASE TABLE",
|
24
|
+
"columns": [],
|
25
|
+
"constraints": [],
|
26
|
+
}
|
27
|
+
|
28
|
+
if cls.db is None:
|
29
|
+
# This class has no database mapping, we must generate a default key for it
|
30
|
+
table["columns"].append(
|
31
|
+
{
|
32
|
+
"name": DUMMY_IDPROP,
|
33
|
+
"type": "UNSET",
|
34
|
+
"nullable": False,
|
35
|
+
}
|
36
|
+
)
|
123
37
|
|
124
|
-
|
125
|
-
|
38
|
+
for prop in cls.properties.values():
|
39
|
+
column = {
|
40
|
+
"name": prop.name,
|
41
|
+
"nullable": not prop.is_optional,
|
42
|
+
}
|
43
|
+
|
44
|
+
if isinstance(prop, DataProperty):
|
45
|
+
column["type"] = prop.datatype.get_python_type().type
|
46
|
+
if prop.db is not None and prop.db.is_primary_key:
|
47
|
+
table["constraints"].append(
|
48
|
+
{
|
49
|
+
"name": f"{cls.name}_pkey",
|
50
|
+
"type": "PRIMARY KEY",
|
51
|
+
"def": f"PRIMARY KEY ({prop.name})",
|
52
|
+
"table": cls.name,
|
53
|
+
"referenced_table": cls.name,
|
54
|
+
"columns": [prop.name],
|
55
|
+
}
|
56
|
+
)
|
57
|
+
else:
|
58
|
+
if prop.cardinality == Cardinality.MANY_TO_MANY:
|
59
|
+
# For many-to-many relationships, we need to create a join table
|
60
|
+
jointable = {
|
61
|
+
"name": f"{cls.name}{prop.target.name}",
|
62
|
+
"type": "JOIN TABLE",
|
63
|
+
"columns": [
|
64
|
+
{
|
65
|
+
"name": f"{to_snake_case(cls.name)}_id",
|
66
|
+
"type": assert_not_null(cls.get_id_property())
|
67
|
+
.datatype.get_python_type()
|
68
|
+
.type,
|
69
|
+
"nullable": False,
|
70
|
+
},
|
71
|
+
{
|
72
|
+
"name": f"{to_snake_case(prop.target.name)}_id",
|
73
|
+
"type": assert_not_null(prop.target.get_id_property())
|
74
|
+
.datatype.get_python_type()
|
75
|
+
.type,
|
76
|
+
"nullable": False,
|
77
|
+
},
|
78
|
+
],
|
79
|
+
}
|
80
|
+
out["tables"].append(jointable)
|
81
|
+
out["relations"].extend(
|
82
|
+
[
|
83
|
+
{
|
84
|
+
"table": f"{cls.name}{prop.target.name}",
|
85
|
+
"columns": [f"{to_snake_case(cls.name)}_id"],
|
86
|
+
"cardinality": "zero_or_more",
|
87
|
+
"parent_table": cls.name,
|
88
|
+
"parent_columns": [
|
89
|
+
assert_not_null(cls.get_id_property()).name
|
90
|
+
],
|
91
|
+
"parent_cardinality": "zero_or_one",
|
92
|
+
"def": "", # LiamERD does not use `def` so we can leave it empty for now
|
93
|
+
},
|
94
|
+
{
|
95
|
+
"table": f"{cls.name}{prop.target.name}",
|
96
|
+
"columns": [f"{to_snake_case(prop.target.name)}_id"],
|
97
|
+
"cardinality": "zero_or_more",
|
98
|
+
"parent_table": prop.target.name,
|
99
|
+
"parent_columns": [
|
100
|
+
assert_not_null(prop.target.get_id_property()).name
|
101
|
+
],
|
102
|
+
"parent_cardinality": "zero_or_one",
|
103
|
+
"def": "",
|
104
|
+
},
|
105
|
+
]
|
106
|
+
)
|
107
|
+
# we actually want to skip adding this N-to-N column
|
108
|
+
continue
|
109
|
+
|
110
|
+
if prop.target.db is not None:
|
111
|
+
idprop = assert_not_null(prop.target.get_id_property())
|
112
|
+
idprop_name = idprop.name
|
113
|
+
column["type"] = idprop.datatype.get_python_type().type
|
114
|
+
else:
|
115
|
+
column["type"] = prop.target.name
|
116
|
+
idprop_name = (
|
117
|
+
DUMMY_IDPROP # a dummy property name for visualization purposes
|
118
|
+
)
|
119
|
+
assert idprop_name not in prop.target.properties
|
120
|
+
|
121
|
+
# somehow LiamERD only support zero_or_more or zero_or_one (exactly one does not work)
|
122
|
+
if prop.cardinality == Cardinality.ONE_TO_ONE:
|
123
|
+
cardinality = "zero_or_one"
|
124
|
+
parent_cardinality = "zero_or_one"
|
125
|
+
elif prop.cardinality == Cardinality.ONE_TO_MANY:
|
126
|
+
cardinality = "zero_or_one"
|
127
|
+
parent_cardinality = "zero_or_more"
|
128
|
+
elif prop.cardinality == Cardinality.MANY_TO_ONE:
|
129
|
+
cardinality = "zero_or_more"
|
130
|
+
parent_cardinality = "zero_or_one"
|
131
|
+
elif prop.cardinality == Cardinality.MANY_TO_MANY:
|
132
|
+
raise Exception("Unreachable")
|
133
|
+
|
134
|
+
out["relations"].append(
|
135
|
+
{
|
136
|
+
"table": cls.name,
|
137
|
+
"columns": [prop.name],
|
138
|
+
"cardinality": cardinality,
|
139
|
+
"parent_table": prop.target.name,
|
140
|
+
"parent_columns": [idprop_name],
|
141
|
+
"parent_cardinality": parent_cardinality,
|
142
|
+
"def": f"FOREIGN KEY ({prop.name}) REFERENCES {prop.target.name}({idprop_name})",
|
143
|
+
}
|
144
|
+
)
|
145
|
+
|
146
|
+
table["columns"].append(column)
|
147
|
+
|
148
|
+
out["tables"].append(table)
|
149
|
+
|
150
|
+
outfile.parent.mkdir(parents=True, exist_ok=True)
|
151
|
+
outfile.write_bytes(orjson.dumps(out, option=orjson.OPT_INDENT_2))
|
126
152
|
|
127
153
|
|
128
154
|
app = typer.Typer(pretty_exceptions_short=True, pretty_exceptions_enable=False)
|
@@ -139,7 +165,7 @@ def cli(
|
|
139
165
|
outfile: Annotated[
|
140
166
|
Path,
|
141
167
|
typer.Option(
|
142
|
-
"-o", "--output", help="Output file for the
|
168
|
+
"-o", "--output", help="Output file for the tbls schema", writable=True
|
143
169
|
),
|
144
170
|
],
|
145
171
|
):
|
@@ -147,7 +173,7 @@ def cli(
|
|
147
173
|
"sera",
|
148
174
|
schema_files,
|
149
175
|
)
|
150
|
-
|
176
|
+
export_tbls(
|
151
177
|
schema,
|
152
178
|
outfile,
|
153
179
|
)
|
@@ -0,0 +1,43 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from litestar import Litestar
|
4
|
+
from litestar.testing import TestClient
|
5
|
+
|
6
|
+
|
7
|
+
def test_has(
|
8
|
+
client: TestClient[Litestar],
|
9
|
+
base_url: str,
|
10
|
+
exist_records: list[int | str],
|
11
|
+
non_exist_records: list[int | str],
|
12
|
+
) -> None:
|
13
|
+
for record in exist_records:
|
14
|
+
resp = client.head(f"{base_url}/{record}")
|
15
|
+
assert (
|
16
|
+
resp.status_code == 204
|
17
|
+
), f"Record {record} should exist but got {resp.status_code}"
|
18
|
+
|
19
|
+
for record in non_exist_records:
|
20
|
+
resp = client.head(f"{base_url}/{record}")
|
21
|
+
assert (
|
22
|
+
resp.status_code == 404
|
23
|
+
), f"Record {record} should not exist but got {resp.status_code}"
|
24
|
+
|
25
|
+
|
26
|
+
def test_get_by_id(
|
27
|
+
client: TestClient[Litestar],
|
28
|
+
base_url: str,
|
29
|
+
exist_records: dict[int | str, dict],
|
30
|
+
non_exist_records: list[int | str],
|
31
|
+
) -> None:
|
32
|
+
for record, data in exist_records.items():
|
33
|
+
resp = client.get(f"{base_url}/{record}")
|
34
|
+
assert (
|
35
|
+
resp.status_code == 200
|
36
|
+
), f"Record {record} should exist but got {resp.status_code}"
|
37
|
+
assert resp.json() == data
|
38
|
+
|
39
|
+
for record in non_exist_records:
|
40
|
+
resp = client.get(f"{base_url}/{record}")
|
41
|
+
assert (
|
42
|
+
resp.status_code == 404
|
43
|
+
), f"Record {record} should not exist but got {resp.status_code}"
|
sera/make/make_python_api.py
CHANGED
@@ -7,8 +7,7 @@ from loguru import logger
|
|
7
7
|
|
8
8
|
from sera.misc import assert_not_null, to_snake_case
|
9
9
|
from sera.models import App, DataCollection, Module, Package, SystemControlledMode
|
10
|
-
|
11
|
-
GLOBAL_IDENTS = {"AsyncSession": "sqlalchemy.ext.asyncio.AsyncSession"}
|
10
|
+
from sera.typing import GLOBAL_IDENTS
|
12
11
|
|
13
12
|
|
14
13
|
def make_python_api(app: App, collections: Sequence[DataCollection]):
|
@@ -544,19 +543,20 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
|
|
544
543
|
)
|
545
544
|
program.import_(
|
546
545
|
app.models.data.path
|
547
|
-
+ f".{collection.get_pymodule_name()}.
|
546
|
+
+ f".{collection.get_pymodule_name()}.Create{collection.name}",
|
548
547
|
True,
|
549
548
|
)
|
550
549
|
|
551
550
|
# assuming the collection has only one class
|
552
551
|
cls = collection.cls
|
553
|
-
|
554
|
-
prop.data.
|
552
|
+
is_on_create_update_props = any(
|
553
|
+
prop.data.system_controlled is not None
|
554
|
+
and prop.data.system_controlled.is_on_create_value_updated()
|
555
555
|
for prop in cls.properties.values()
|
556
556
|
)
|
557
557
|
idprop = assert_not_null(cls.get_id_property())
|
558
558
|
|
559
|
-
if
|
559
|
+
if is_on_create_update_props:
|
560
560
|
program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
|
561
561
|
|
562
562
|
func_name = "create"
|
@@ -575,11 +575,11 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
|
|
575
575
|
"dto",
|
576
576
|
PredefinedFn.item_getter(
|
577
577
|
expr.ExprIdent("SingleAutoUSCP"),
|
578
|
-
expr.ExprIdent(f"
|
578
|
+
expr.ExprIdent(f"Create{cls.name}"),
|
579
579
|
),
|
580
580
|
)
|
581
581
|
]
|
582
|
-
if
|
582
|
+
if is_on_create_update_props
|
583
583
|
else []
|
584
584
|
),
|
585
585
|
)
|
@@ -589,7 +589,7 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
|
|
589
589
|
[
|
590
590
|
DeferredVar.simple(
|
591
591
|
"data",
|
592
|
-
expr.ExprIdent(f"
|
592
|
+
expr.ExprIdent(f"Create{cls.name}"),
|
593
593
|
),
|
594
594
|
DeferredVar.simple(
|
595
595
|
"session",
|
@@ -652,7 +652,7 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
|
|
652
652
|
)
|
653
653
|
program.import_(
|
654
654
|
app.models.data.path
|
655
|
-
+ f".{collection.get_pymodule_name()}.
|
655
|
+
+ f".{collection.get_pymodule_name()}.Update{collection.name}",
|
656
656
|
True,
|
657
657
|
)
|
658
658
|
|
@@ -661,11 +661,12 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
|
|
661
661
|
id_prop = assert_not_null(cls.get_id_property())
|
662
662
|
id_type = id_prop.datatype.get_python_type().type
|
663
663
|
|
664
|
-
|
665
|
-
prop.data.
|
664
|
+
is_on_update_update_props = any(
|
665
|
+
prop.data.system_controlled is not None
|
666
|
+
and prop.data.system_controlled.is_on_update_value_updated()
|
666
667
|
for prop in cls.properties.values()
|
667
668
|
)
|
668
|
-
if
|
669
|
+
if is_on_update_update_props:
|
669
670
|
program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
|
670
671
|
|
671
672
|
func_name = "update"
|
@@ -684,11 +685,11 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
|
|
684
685
|
"dto",
|
685
686
|
PredefinedFn.item_getter(
|
686
687
|
expr.ExprIdent("SingleAutoUSCP"),
|
687
|
-
expr.ExprIdent(f"
|
688
|
+
expr.ExprIdent(f"Update{cls.name}"),
|
688
689
|
),
|
689
690
|
)
|
690
691
|
]
|
691
|
-
if
|
692
|
+
if is_on_update_update_props
|
692
693
|
else []
|
693
694
|
),
|
694
695
|
)
|
@@ -702,7 +703,7 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
|
|
702
703
|
),
|
703
704
|
DeferredVar.simple(
|
704
705
|
"data",
|
705
|
-
expr.ExprIdent(f"
|
706
|
+
expr.ExprIdent(f"Update{cls.name}"),
|
706
707
|
),
|
707
708
|
DeferredVar.simple(
|
708
709
|
"session",
|
sera/make/make_python_model.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
|
3
|
+
import sys
|
4
|
+
from ast import Not
|
5
|
+
from typing import Callable, Literal, Optional, Sequence
|
4
6
|
|
5
7
|
from codegen.models import (
|
6
8
|
AST,
|
@@ -28,7 +30,7 @@ from sera.models import (
|
|
28
30
|
Schema,
|
29
31
|
)
|
30
32
|
from sera.models._property import SystemControlledMode
|
31
|
-
from sera.typing import ObjectPath
|
33
|
+
from sera.typing import GLOBAL_IDENTS, ObjectPath
|
32
34
|
|
33
35
|
|
34
36
|
def make_python_enums(
|
@@ -144,6 +146,7 @@ def make_python_data_model(
|
|
144
146
|
program: Program,
|
145
147
|
slf: expr.ExprIdent,
|
146
148
|
cls: Class,
|
149
|
+
mode: Literal["create", "update"],
|
147
150
|
prop: DataProperty | ObjectProperty,
|
148
151
|
):
|
149
152
|
value = PredefinedFn.attr_getter(slf, expr.ExprIdent(prop.name))
|
@@ -194,7 +197,7 @@ def make_python_data_model(
|
|
194
197
|
prop.datatype.get_python_type().type,
|
195
198
|
)(value)
|
196
199
|
|
197
|
-
if prop.data.is_private:
|
200
|
+
if mode == "update" and prop.data.is_private:
|
198
201
|
# if the property is private and it's UNSET, we cannot transform it to the database type
|
199
202
|
# and has to use the UNSET value (the update query will ignore this field)
|
200
203
|
program.import_("sera.typing.UNSET", True)
|
@@ -208,7 +211,133 @@ def make_python_data_model(
|
|
208
211
|
value = converted_value
|
209
212
|
return value
|
210
213
|
|
211
|
-
def
|
214
|
+
def make_uscp_func(
|
215
|
+
cls: Class,
|
216
|
+
mode: Literal["create", "update"],
|
217
|
+
ast: AST,
|
218
|
+
ident_manager: ImportHelper,
|
219
|
+
):
|
220
|
+
func = ast.func(
|
221
|
+
"update_system_controlled_props",
|
222
|
+
[
|
223
|
+
DeferredVar.simple("self"),
|
224
|
+
DeferredVar.simple(
|
225
|
+
"conn",
|
226
|
+
ident_manager.use("ASGIConnection"),
|
227
|
+
),
|
228
|
+
],
|
229
|
+
)
|
230
|
+
|
231
|
+
assign_user = False
|
232
|
+
|
233
|
+
for prop in cls.properties.values():
|
234
|
+
if prop.data.system_controlled is None:
|
235
|
+
continue
|
236
|
+
|
237
|
+
update_func = None
|
238
|
+
if mode == "create":
|
239
|
+
if prop.data.system_controlled.on_create_bypass is not None:
|
240
|
+
# by-pass the update function are handled later
|
241
|
+
continue
|
242
|
+
|
243
|
+
if prop.data.system_controlled.is_on_create_value_updated():
|
244
|
+
update_func = (
|
245
|
+
prop.data.system_controlled.get_on_create_update_func()
|
246
|
+
)
|
247
|
+
else:
|
248
|
+
if prop.data.system_controlled.on_update_bypass is not None:
|
249
|
+
# by-pass the update function are handled later
|
250
|
+
continue
|
251
|
+
if prop.data.system_controlled.is_on_update_value_updated():
|
252
|
+
update_func = (
|
253
|
+
prop.data.system_controlled.get_on_update_update_func()
|
254
|
+
)
|
255
|
+
|
256
|
+
if update_func is None:
|
257
|
+
continue
|
258
|
+
|
259
|
+
if update_func.func == "getattr":
|
260
|
+
if update_func.args[0] == "user":
|
261
|
+
if len(update_func.args) != 2:
|
262
|
+
raise NotImplementedError(
|
263
|
+
f"Unsupported update function: {update_func.func} with args {update_func.args}"
|
264
|
+
)
|
265
|
+
if not assign_user:
|
266
|
+
func(
|
267
|
+
stmt.AssignStatement(
|
268
|
+
expr.ExprIdent("user"),
|
269
|
+
PredefinedFn.item_getter(
|
270
|
+
PredefinedFn.attr_getter(
|
271
|
+
expr.ExprIdent("conn"),
|
272
|
+
expr.ExprIdent("scope"),
|
273
|
+
),
|
274
|
+
expr.ExprConstant("user"),
|
275
|
+
),
|
276
|
+
)
|
277
|
+
)
|
278
|
+
assign_user = True
|
279
|
+
epr = PredefinedFn.attr_getter(
|
280
|
+
expr.ExprIdent("user"), expr.ExprIdent(update_func.args[1])
|
281
|
+
)
|
282
|
+
elif update_func.args[0] == "self":
|
283
|
+
epr = PredefinedFn.attr_getter(
|
284
|
+
expr.ExprIdent("self"), expr.ExprIdent(update_func.args[1])
|
285
|
+
)
|
286
|
+
else:
|
287
|
+
raise NotImplementedError(
|
288
|
+
f"Unsupported update function: {update_func.func} with args {update_func.args}"
|
289
|
+
)
|
290
|
+
else:
|
291
|
+
raise NotImplementedError(update_func.func)
|
292
|
+
|
293
|
+
smt = stmt.AssignStatement(
|
294
|
+
PredefinedFn.attr_getter(
|
295
|
+
expr.ExprIdent("self"), expr.ExprIdent(prop.name)
|
296
|
+
),
|
297
|
+
epr,
|
298
|
+
)
|
299
|
+
func(smt)
|
300
|
+
|
301
|
+
# handle the by-pass properties here
|
302
|
+
for prop in cls.properties.values():
|
303
|
+
if prop.data.system_controlled is None:
|
304
|
+
continue
|
305
|
+
|
306
|
+
update_func = None
|
307
|
+
if mode == "create":
|
308
|
+
if prop.data.system_controlled.on_create_bypass is None:
|
309
|
+
# non by-pass the update function are handled earlier
|
310
|
+
continue
|
311
|
+
|
312
|
+
if prop.data.system_controlled.is_on_create_value_updated():
|
313
|
+
update_func = (
|
314
|
+
prop.data.system_controlled.get_on_create_update_func()
|
315
|
+
)
|
316
|
+
else:
|
317
|
+
if prop.data.system_controlled.on_update_bypass is None:
|
318
|
+
# non by-pass the update function are handled earlier
|
319
|
+
continue
|
320
|
+
|
321
|
+
if prop.data.system_controlled.is_on_update_value_updated():
|
322
|
+
update_func = (
|
323
|
+
prop.data.system_controlled.get_on_update_update_func()
|
324
|
+
)
|
325
|
+
|
326
|
+
if update_func is None:
|
327
|
+
continue
|
328
|
+
|
329
|
+
raise NotImplementedError("We haven't handled the by-pass properties yet.")
|
330
|
+
|
331
|
+
func(
|
332
|
+
stmt.AssignStatement(
|
333
|
+
PredefinedFn.attr_getter(
|
334
|
+
expr.ExprIdent("self"), expr.ExprIdent("_is_scp_updated")
|
335
|
+
),
|
336
|
+
expr.ExprConstant(True),
|
337
|
+
)
|
338
|
+
)
|
339
|
+
|
340
|
+
def make_create(program: Program, cls: Class):
|
212
341
|
program.import_("__future__.annotations", True)
|
213
342
|
program.import_("msgspec", False)
|
214
343
|
if cls.db is not None:
|
@@ -221,52 +350,214 @@ def make_python_data_model(
|
|
221
350
|
|
222
351
|
ident_manager = ImportHelper(
|
223
352
|
program,
|
224
|
-
|
225
|
-
"UNSET": "sera.typing.UNSET",
|
226
|
-
},
|
353
|
+
GLOBAL_IDENTS,
|
227
354
|
)
|
228
355
|
|
229
|
-
|
230
|
-
|
231
|
-
prop.data.
|
356
|
+
is_on_create_value_updated = any(
|
357
|
+
prop.data.system_controlled is not None
|
358
|
+
and prop.data.system_controlled.is_on_create_value_updated()
|
232
359
|
for prop in cls.properties.values()
|
233
360
|
)
|
234
|
-
|
235
|
-
|
236
|
-
|
361
|
+
program.root.linebreak()
|
362
|
+
cls_ast = program.root.class_(
|
363
|
+
"Create" + cls.name,
|
364
|
+
[expr.ExprIdent("msgspec.Struct"), expr.ExprIdent("kw_only=True")],
|
365
|
+
)
|
366
|
+
for prop in cls.properties.values():
|
367
|
+
# Skip fields that are system-controlled (e.g., cached or derived fields)
|
368
|
+
# and cannot be updated based on information parsed from the request.
|
369
|
+
if (
|
370
|
+
prop.data.system_controlled is not None
|
371
|
+
and prop.data.system_controlled.is_on_create_ignored()
|
372
|
+
):
|
373
|
+
continue
|
374
|
+
|
375
|
+
if isinstance(prop, DataProperty):
|
376
|
+
pytype = prop.get_data_model_datatype().get_python_type()
|
377
|
+
if prop.is_optional:
|
378
|
+
pytype = pytype.as_optional_type()
|
379
|
+
|
380
|
+
for dep in pytype.deps:
|
381
|
+
program.import_(dep, True)
|
382
|
+
|
383
|
+
pytype_type = pytype.type
|
384
|
+
if len(prop.data.constraints) > 0:
|
385
|
+
# if the property has constraints, we need to figure out
|
386
|
+
program.import_("typing.Annotated", True)
|
387
|
+
if len(prop.data.constraints) == 1:
|
388
|
+
pytype_type = f"Annotated[%s, %s]" % (
|
389
|
+
pytype_type,
|
390
|
+
prop.data.constraints[0].get_msgspec_constraint(),
|
391
|
+
)
|
392
|
+
else:
|
393
|
+
raise NotImplementedError(prop.data.constraints)
|
394
|
+
|
395
|
+
# private property are available for creating, but not for updating.
|
396
|
+
# so we do not need to skip it.
|
397
|
+
# if prop.data.is_private:
|
398
|
+
# program.import_("typing.Union", True)
|
399
|
+
# program.import_("sera.typing.UnsetType", True)
|
400
|
+
# program.import_("sera.typing.UNSET", True)
|
401
|
+
# pytype_type = f"Union[{pytype_type}, UnsetType]"
|
402
|
+
|
403
|
+
prop_default_value = None
|
404
|
+
# if prop.data.is_private:
|
405
|
+
# prop_default_value = expr.ExprIdent("UNSET")
|
406
|
+
if prop.default_value is not None:
|
407
|
+
prop_default_value = expr.ExprConstant(prop.default_value)
|
408
|
+
elif prop.default_factory is not None:
|
409
|
+
program.import_(prop.default_factory.pyfunc, True)
|
410
|
+
prop_default_value = expr.ExprFuncCall(
|
411
|
+
expr.ExprIdent("msgspec.field"),
|
412
|
+
[
|
413
|
+
PredefinedFn.keyword_assignment(
|
414
|
+
"default_factory",
|
415
|
+
expr.ExprIdent(prop.default_factory.pyfunc),
|
416
|
+
)
|
417
|
+
],
|
418
|
+
)
|
419
|
+
|
420
|
+
cls_ast(
|
421
|
+
stmt.DefClassVarStatement(
|
422
|
+
prop.name, pytype_type, prop_default_value
|
423
|
+
)
|
424
|
+
)
|
425
|
+
elif isinstance(prop, ObjectProperty):
|
426
|
+
if prop.target.db is not None:
|
427
|
+
# if the target class is in the database, we expect the user to pass the foreign key for it.
|
428
|
+
pytype = (
|
429
|
+
assert_not_null(prop.target.get_id_property())
|
430
|
+
.get_data_model_datatype()
|
431
|
+
.get_python_type()
|
432
|
+
)
|
433
|
+
else:
|
434
|
+
pytype = PyTypeWithDep(
|
435
|
+
f"Create{prop.target.name}",
|
436
|
+
[
|
437
|
+
f"{target_pkg.module(prop.target.get_pymodule_name()).path}.Create{prop.target.name}"
|
438
|
+
],
|
439
|
+
)
|
440
|
+
|
441
|
+
if prop.cardinality.is_star_to_many():
|
442
|
+
pytype = pytype.as_list_type()
|
443
|
+
elif prop.is_optional:
|
444
|
+
pytype = pytype.as_optional_type()
|
445
|
+
|
446
|
+
pytype_type = pytype.type
|
447
|
+
|
448
|
+
for dep in pytype.deps:
|
449
|
+
program.import_(dep, True)
|
450
|
+
|
451
|
+
cls_ast(stmt.DefClassVarStatement(prop.name, pytype_type))
|
452
|
+
|
453
|
+
if is_on_create_value_updated:
|
454
|
+
program.import_("typing.Optional", True)
|
455
|
+
program.import_("sera.typing.is_set", True)
|
456
|
+
cls_ast(
|
457
|
+
stmt.Comment(
|
458
|
+
"A marker to indicate that the system-controlled properties are updated"
|
459
|
+
),
|
460
|
+
stmt.DefClassVarStatement(
|
461
|
+
"_is_scp_updated", "bool", expr.ExprConstant(False)
|
462
|
+
),
|
237
463
|
stmt.LineBreak(),
|
238
|
-
lambda ast: ast.
|
239
|
-
"
|
240
|
-
[
|
464
|
+
lambda ast: ast.func(
|
465
|
+
"__post_init__",
|
466
|
+
[
|
467
|
+
DeferredVar.simple("self"),
|
468
|
+
],
|
241
469
|
)(
|
242
|
-
|
243
|
-
|
244
|
-
|
470
|
+
stmt.AssignStatement(
|
471
|
+
PredefinedFn.attr_getter(
|
472
|
+
expr.ExprIdent("self"), expr.ExprIdent("_is_scp_updated")
|
473
|
+
),
|
474
|
+
expr.ExprConstant(False),
|
475
|
+
),
|
476
|
+
),
|
477
|
+
stmt.LineBreak(),
|
478
|
+
lambda ast: make_uscp_func(cls, "create", ast, ident_manager),
|
479
|
+
)
|
480
|
+
|
481
|
+
cls_ast(
|
482
|
+
stmt.LineBreak(),
|
483
|
+
lambda ast00: ast00.func(
|
484
|
+
"to_db",
|
485
|
+
[
|
486
|
+
DeferredVar.simple("self"),
|
487
|
+
],
|
488
|
+
return_type=expr.ExprIdent(
|
489
|
+
f"{cls.name}DB" if cls.db is not None else cls.name
|
490
|
+
),
|
491
|
+
)(
|
492
|
+
(
|
493
|
+
stmt.AssertionStatement(
|
494
|
+
PredefinedFn.attr_getter(
|
495
|
+
expr.ExprIdent("self"),
|
496
|
+
expr.ExprIdent("_is_scp_updated"),
|
497
|
+
),
|
498
|
+
expr.ExprConstant(
|
499
|
+
"The model data must be verified before converting to db model"
|
500
|
+
),
|
501
|
+
)
|
502
|
+
if is_on_create_value_updated
|
503
|
+
else None
|
504
|
+
),
|
505
|
+
lambda ast10: ast10.return_(
|
506
|
+
expr.ExprFuncCall(
|
507
|
+
expr.ExprIdent(
|
508
|
+
f"{cls.name}DB" if cls.db is not None else cls.name
|
509
|
+
),
|
510
|
+
[
|
245
511
|
(
|
246
|
-
|
247
|
-
if
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
)
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
== SystemControlledMode.RESTRICTED
|
257
|
-
],
|
512
|
+
ident_manager.use("UNSET")
|
513
|
+
if prop.data.system_controlled is not None
|
514
|
+
and prop.data.system_controlled.is_on_create_ignored()
|
515
|
+
else to_db_type_conversion(
|
516
|
+
program, expr.ExprIdent("self"), cls, "create", prop
|
517
|
+
)
|
518
|
+
)
|
519
|
+
for prop in cls.properties.values()
|
520
|
+
],
|
521
|
+
)
|
258
522
|
),
|
523
|
+
),
|
524
|
+
)
|
525
|
+
|
526
|
+
def make_update(program: Program, cls: Class):
|
527
|
+
program.import_("__future__.annotations", True)
|
528
|
+
program.import_("msgspec", False)
|
529
|
+
if cls.db is not None:
|
530
|
+
# if the class is stored in the database, we need to import the database module
|
531
|
+
program.import_(
|
532
|
+
app.models.db.path + f".{cls.get_pymodule_name()}.{cls.name}",
|
533
|
+
True,
|
534
|
+
alias=f"{cls.name}DB",
|
259
535
|
)
|
260
536
|
|
537
|
+
ident_manager = ImportHelper(
|
538
|
+
program,
|
539
|
+
GLOBAL_IDENTS,
|
540
|
+
)
|
541
|
+
|
542
|
+
# property that normal users cannot set, but super users can
|
543
|
+
is_on_update_value_updated = any(
|
544
|
+
prop.data.system_controlled is not None
|
545
|
+
and prop.data.system_controlled.is_on_update_value_updated()
|
546
|
+
for prop in cls.properties.values()
|
547
|
+
)
|
548
|
+
|
261
549
|
program.root.linebreak()
|
262
550
|
cls_ast = program.root.class_(
|
263
|
-
"
|
551
|
+
"Update" + cls.name,
|
264
552
|
[expr.ExprIdent("msgspec.Struct"), expr.ExprIdent("kw_only=True")],
|
265
553
|
)
|
266
554
|
for prop in cls.properties.values():
|
267
|
-
#
|
268
|
-
#
|
269
|
-
if
|
555
|
+
# Skip fields that are system-controlled (e.g., cached or derived fields)
|
556
|
+
# and cannot be updated based on information parsed from the request.
|
557
|
+
if (
|
558
|
+
prop.data.system_controlled is not None
|
559
|
+
and prop.data.system_controlled.is_on_update_ignored()
|
560
|
+
):
|
270
561
|
continue
|
271
562
|
|
272
563
|
if isinstance(prop, DataProperty):
|
@@ -289,20 +580,14 @@ def make_python_data_model(
|
|
289
580
|
else:
|
290
581
|
raise NotImplementedError(prop.data.constraints)
|
291
582
|
|
292
|
-
if
|
293
|
-
prop.data.is_private
|
294
|
-
or prop.data.is_system_controlled == SystemControlledMode.RESTRICTED
|
295
|
-
):
|
583
|
+
if prop.data.is_private:
|
296
584
|
program.import_("typing.Union", True)
|
297
585
|
program.import_("sera.typing.UnsetType", True)
|
298
586
|
program.import_("sera.typing.UNSET", True)
|
299
587
|
pytype_type = f"Union[{pytype_type}, UnsetType]"
|
300
588
|
|
301
589
|
prop_default_value = None
|
302
|
-
if
|
303
|
-
prop.data.is_private
|
304
|
-
or prop.data.is_system_controlled == SystemControlledMode.RESTRICTED
|
305
|
-
):
|
590
|
+
if prop.data.is_private:
|
306
591
|
prop_default_value = expr.ExprIdent("UNSET")
|
307
592
|
elif prop.default_value is not None:
|
308
593
|
prop_default_value = expr.ExprConstant(prop.default_value)
|
@@ -333,9 +618,9 @@ def make_python_data_model(
|
|
333
618
|
)
|
334
619
|
else:
|
335
620
|
pytype = PyTypeWithDep(
|
336
|
-
f"
|
621
|
+
f"Update{prop.target.name}",
|
337
622
|
[
|
338
|
-
f"{target_pkg.module(prop.target.get_pymodule_name()).path}.
|
623
|
+
f"{target_pkg.module(prop.target.get_pymodule_name()).path}.Update{prop.target.name}"
|
339
624
|
],
|
340
625
|
)
|
341
626
|
|
@@ -345,77 +630,38 @@ def make_python_data_model(
|
|
345
630
|
pytype = pytype.as_optional_type()
|
346
631
|
|
347
632
|
pytype_type = pytype.type
|
348
|
-
prop_default_value = None
|
349
|
-
if prop.data.is_system_controlled == SystemControlledMode.RESTRICTED:
|
350
|
-
program.import_("typing.Union", True)
|
351
|
-
program.import_("sera.typing.UnsetType", True)
|
352
|
-
program.import_("sera.typing.UNSET", True)
|
353
|
-
pytype_type = f"Union[{pytype_type}, UnsetType]"
|
354
|
-
prop_default_value = expr.ExprIdent("UNSET")
|
355
633
|
|
356
634
|
for dep in pytype.deps:
|
357
635
|
program.import_(dep, True)
|
358
636
|
|
359
|
-
cls_ast(
|
360
|
-
stmt.DefClassVarStatement(
|
361
|
-
prop.name, pytype_type, prop_default_value
|
362
|
-
)
|
363
|
-
)
|
637
|
+
cls_ast(stmt.DefClassVarStatement(prop.name, pytype_type))
|
364
638
|
|
365
|
-
if
|
639
|
+
if is_on_update_value_updated:
|
366
640
|
program.import_("typing.Optional", True)
|
367
641
|
program.import_("sera.typing.is_set", True)
|
368
642
|
cls_ast(
|
369
|
-
stmt.
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
],
|
375
|
-
)(
|
376
|
-
*[
|
377
|
-
stmt.AssignStatement(
|
378
|
-
PredefinedFn.attr_getter(
|
379
|
-
expr.ExprIdent("self"), expr.ExprIdent(prop.name)
|
380
|
-
),
|
381
|
-
expr.ExprIdent("UNSET"),
|
382
|
-
)
|
383
|
-
for prop in cls.properties.values()
|
384
|
-
if prop.data.is_system_controlled
|
385
|
-
== SystemControlledMode.RESTRICTED
|
386
|
-
]
|
643
|
+
stmt.Comment(
|
644
|
+
"A marker to indicate that the system-controlled properties are updated"
|
645
|
+
),
|
646
|
+
stmt.DefClassVarStatement(
|
647
|
+
"_is_scp_updated", "bool", expr.ExprConstant(False)
|
387
648
|
),
|
388
649
|
stmt.LineBreak(),
|
389
650
|
lambda ast: ast.func(
|
390
|
-
"
|
651
|
+
"__post_init__",
|
391
652
|
[
|
392
653
|
DeferredVar.simple("self"),
|
393
|
-
DeferredVar.simple(
|
394
|
-
"data",
|
395
|
-
expr.ExprIdent("Optional[SystemControlledProps]"),
|
396
|
-
),
|
397
654
|
],
|
398
655
|
)(
|
399
|
-
|
400
|
-
|
401
|
-
expr.
|
656
|
+
stmt.AssignStatement(
|
657
|
+
PredefinedFn.attr_getter(
|
658
|
+
expr.ExprIdent("self"), expr.ExprIdent("_is_scp_updated")
|
402
659
|
),
|
403
|
-
|
404
|
-
*[
|
405
|
-
stmt.AssignStatement(
|
406
|
-
PredefinedFn.attr_getter(
|
407
|
-
expr.ExprIdent("self"), expr.ExprIdent(prop.name)
|
408
|
-
),
|
409
|
-
PredefinedFn.item_getter(
|
410
|
-
expr.ExprIdent("data"), expr.ExprConstant(prop.name)
|
411
|
-
),
|
412
|
-
)
|
413
|
-
for prop in cls.properties.values()
|
414
|
-
if prop.data.is_system_controlled
|
415
|
-
== SystemControlledMode.RESTRICTED
|
416
|
-
]
|
660
|
+
expr.ExprConstant(False),
|
417
661
|
),
|
418
662
|
),
|
663
|
+
stmt.LineBreak(),
|
664
|
+
lambda ast: make_uscp_func(cls, "update", ast, ident_manager),
|
419
665
|
)
|
420
666
|
|
421
667
|
cls_ast(
|
@@ -443,15 +689,15 @@ def make_python_data_model(
|
|
443
689
|
],
|
444
690
|
)
|
445
691
|
for prop in cls.properties.values()
|
446
|
-
if prop.data.
|
447
|
-
|
692
|
+
if prop.data.system_controlled is not None
|
693
|
+
and prop.data.system_controlled.is_on_update_value_updated()
|
448
694
|
]
|
449
695
|
),
|
450
696
|
expr.ExprConstant(
|
451
697
|
"The model data must be verified before converting to db model"
|
452
698
|
),
|
453
699
|
)
|
454
|
-
if
|
700
|
+
if is_on_update_value_updated
|
455
701
|
else None
|
456
702
|
),
|
457
703
|
lambda ast10: ast10.return_(
|
@@ -461,12 +707,12 @@ def make_python_data_model(
|
|
461
707
|
),
|
462
708
|
[
|
463
709
|
(
|
464
|
-
|
465
|
-
|
710
|
+
ident_manager.use("UNSET")
|
711
|
+
if prop.data.system_controlled is not None
|
712
|
+
and prop.data.system_controlled.is_on_update_ignored()
|
713
|
+
else to_db_type_conversion(
|
714
|
+
program, expr.ExprIdent("self"), cls, "update", prop
|
466
715
|
)
|
467
|
-
if prop.data.is_system_controlled
|
468
|
-
!= SystemControlledMode.AUTO
|
469
|
-
else ident_manager.use("UNSET")
|
470
716
|
)
|
471
717
|
for prop in cls.properties.values()
|
472
718
|
],
|
@@ -617,7 +863,7 @@ def make_python_data_model(
|
|
617
863
|
PredefinedFn.tuple(
|
618
864
|
[
|
619
865
|
to_db_type_conversion(
|
620
|
-
program, expr.ExprIdent("self"), cls, prop
|
866
|
+
program, expr.ExprIdent("self"), cls, "create", prop
|
621
867
|
)
|
622
868
|
for prop in cls.properties.values()
|
623
869
|
]
|
@@ -631,7 +877,9 @@ def make_python_data_model(
|
|
631
877
|
continue
|
632
878
|
|
633
879
|
program = Program()
|
634
|
-
|
880
|
+
make_create(program, cls)
|
881
|
+
program.root.linebreak()
|
882
|
+
make_update(program, cls)
|
635
883
|
program.root.linebreak()
|
636
884
|
make_normal(program, cls)
|
637
885
|
target_pkg.module(cls.get_pymodule_name()).write(program)
|
sera/models/_parse.py
CHANGED
@@ -5,6 +5,7 @@ from copy import deepcopy
|
|
5
5
|
from pathlib import Path
|
6
6
|
from typing import Sequence
|
7
7
|
|
8
|
+
import orjson
|
8
9
|
import serde.yaml
|
9
10
|
|
10
11
|
from sera.models._class import Class, ClassDBMapInfo, Index
|
@@ -28,9 +29,11 @@ from sera.models._property import (
|
|
28
29
|
DataProperty,
|
29
30
|
ForeignKeyOnDelete,
|
30
31
|
ForeignKeyOnUpdate,
|
32
|
+
GetSCPropValueFunc,
|
31
33
|
ObjectPropDBInfo,
|
32
34
|
ObjectProperty,
|
33
35
|
PropDataAttrs,
|
36
|
+
SystemControlledAttrs,
|
34
37
|
SystemControlledMode,
|
35
38
|
)
|
36
39
|
from sera.models._schema import Schema
|
@@ -135,6 +138,9 @@ def _parse_property(
|
|
135
138
|
is_system_controlled=SystemControlledMode(
|
136
139
|
_data.get("is_system_controlled", SystemControlledMode.NO.value)
|
137
140
|
),
|
141
|
+
system_controlled=_parse_system_controlled_attrs(
|
142
|
+
_data.get("system_controlled")
|
143
|
+
),
|
138
144
|
)
|
139
145
|
|
140
146
|
assert isinstance(prop, dict), prop
|
@@ -286,3 +292,59 @@ def _parse_default_factory(default_factory: dict | None) -> DefaultFactory | Non
|
|
286
292
|
return DefaultFactory(
|
287
293
|
pyfunc=default_factory["pyfunc"], tsfunc=default_factory["tsfunc"]
|
288
294
|
)
|
295
|
+
|
296
|
+
|
297
|
+
def _parse_system_controlled_attrs(
|
298
|
+
attrs: dict | None,
|
299
|
+
) -> SystemControlledAttrs | None:
|
300
|
+
if attrs is None:
|
301
|
+
return None
|
302
|
+
if not isinstance(attrs, dict):
|
303
|
+
raise NotImplementedError(attrs)
|
304
|
+
|
305
|
+
if "on_upsert" in attrs:
|
306
|
+
attrs = attrs.copy()
|
307
|
+
attrs.update(
|
308
|
+
{
|
309
|
+
"on_create": attrs["on_upsert"],
|
310
|
+
"on_create_bypass": attrs.get("on_upsert_bypass"),
|
311
|
+
"on_update": attrs["on_upsert"],
|
312
|
+
"on_update_bypass": attrs.get("on_upsert_bypass"),
|
313
|
+
}
|
314
|
+
)
|
315
|
+
|
316
|
+
if "on_create" not in attrs or "on_update" not in attrs:
|
317
|
+
raise ValueError(
|
318
|
+
"System controlled attributes must have 'on_create', 'on_update', or 'on_upsert' must be defined."
|
319
|
+
)
|
320
|
+
|
321
|
+
keys = {}
|
322
|
+
for key in ["on_create", "on_update"]:
|
323
|
+
if attrs[key] == "ignored":
|
324
|
+
keys[key] = "ignored"
|
325
|
+
elif attrs[key].find(":") != -1:
|
326
|
+
func, args = attrs[key].split(":")
|
327
|
+
assert func == "getattr", f"Unsupported function: {func}"
|
328
|
+
args = orjson.loads(args)
|
329
|
+
keys[key] = GetSCPropValueFunc(
|
330
|
+
func=func,
|
331
|
+
args=args,
|
332
|
+
)
|
333
|
+
else:
|
334
|
+
raise ValueError(
|
335
|
+
f"System controlled attribute '{key}' must be 'ignored' or a function call in the format '<funcname>:<args>'."
|
336
|
+
)
|
337
|
+
|
338
|
+
if attrs[key + "_bypass"] is not None:
|
339
|
+
if not isinstance(attrs[key + "_bypass"], str):
|
340
|
+
raise ValueError(
|
341
|
+
f"System controlled attribute '{key}_bypass' must be a string."
|
342
|
+
)
|
343
|
+
keys[key + "_bypass"] = attrs[key + "_bypass"]
|
344
|
+
|
345
|
+
return SystemControlledAttrs(
|
346
|
+
on_create=keys["on_create"],
|
347
|
+
on_create_bypass=keys.get("on_create_bypass"),
|
348
|
+
on_update=keys["on_update"],
|
349
|
+
on_update_bypass=keys.get("on_update_bypass"),
|
350
|
+
)
|
sera/models/_property.py
CHANGED
@@ -69,6 +69,43 @@ class SystemControlledMode(str, Enum):
|
|
69
69
|
NO = "no"
|
70
70
|
|
71
71
|
|
72
|
+
@dataclass(kw_only=True)
|
73
|
+
class GetSCPropValueFunc:
|
74
|
+
|
75
|
+
func: Literal["getattr"]
|
76
|
+
args: tuple[str, ...]
|
77
|
+
|
78
|
+
|
79
|
+
@dataclass(kw_only=True)
|
80
|
+
class SystemControlledAttrs:
|
81
|
+
"""Attributes for a system-controlled property."""
|
82
|
+
|
83
|
+
on_create_bypass: Optional[str]
|
84
|
+
on_create: Literal["ignored"] | GetSCPropValueFunc
|
85
|
+
on_update_bypass: Optional[str]
|
86
|
+
on_update: Literal["ignored"] | GetSCPropValueFunc
|
87
|
+
|
88
|
+
def is_on_create_value_updated(self) -> bool:
|
89
|
+
return isinstance(self.on_create, GetSCPropValueFunc)
|
90
|
+
|
91
|
+
def get_on_create_update_func(self) -> GetSCPropValueFunc:
|
92
|
+
assert isinstance(self.on_create, GetSCPropValueFunc)
|
93
|
+
return self.on_create
|
94
|
+
|
95
|
+
def is_on_create_ignored(self) -> bool:
|
96
|
+
return self.on_create == "ignored"
|
97
|
+
|
98
|
+
def is_on_update_ignored(self) -> bool:
|
99
|
+
return self.on_update == "ignored"
|
100
|
+
|
101
|
+
def is_on_update_value_updated(self) -> bool:
|
102
|
+
return isinstance(self.on_update, GetSCPropValueFunc)
|
103
|
+
|
104
|
+
def get_on_update_update_func(self) -> GetSCPropValueFunc:
|
105
|
+
assert isinstance(self.on_update, GetSCPropValueFunc)
|
106
|
+
return self.on_update
|
107
|
+
|
108
|
+
|
72
109
|
@dataclass(kw_only=True)
|
73
110
|
class PropDataAttrs:
|
74
111
|
"""Storing other attributes for generating data model (upsert & public) -- this is different from a db model"""
|
@@ -87,6 +124,9 @@ class PropDataAttrs:
|
|
87
124
|
# whether this property is controlled by the system or not
|
88
125
|
is_system_controlled: SystemControlledMode = SystemControlledMode.NO
|
89
126
|
|
127
|
+
# if this property is controlled by the system, the attributes for the system-controlled property
|
128
|
+
system_controlled: Optional[SystemControlledAttrs] = None
|
129
|
+
|
90
130
|
|
91
131
|
@dataclass(kw_only=True)
|
92
132
|
class Property:
|
sera/typing.py
CHANGED
@@ -30,3 +30,11 @@ UNSET: Any = msgspec.UNSET
|
|
30
30
|
def is_set(value: Union[T, UnsetType]) -> TypeGuard[T]:
|
31
31
|
"""Typeguard to check if a value is set (not UNSET)"""
|
32
32
|
return value is not UNSET
|
33
|
+
|
34
|
+
|
35
|
+
# Global identifiers for codegen
|
36
|
+
GLOBAL_IDENTS = {
|
37
|
+
"AsyncSession": "sqlalchemy.ext.asyncio.AsyncSession",
|
38
|
+
"ASGIConnection": "litestar.connection.ASGIConnection",
|
39
|
+
"UNSET": "sera.typing.UNSET",
|
40
|
+
}
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: sera-2
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.14.0
|
4
4
|
Summary:
|
5
5
|
Author: Binh Vu
|
6
6
|
Author-email: bvu687@gmail.com
|
@@ -9,7 +9,7 @@ Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.12
|
10
10
|
Classifier: Programming Language :: Python :: 3.13
|
11
11
|
Requires-Dist: black (>=25.1.0,<26.0.0)
|
12
|
-
Requires-Dist: codegen-2 (>=2.11.
|
12
|
+
Requires-Dist: codegen-2 (>=2.11.1,<3.0.0)
|
13
13
|
Requires-Dist: isort (>=6.0.1,<7.0.0)
|
14
14
|
Requires-Dist: litestar (>=2.15.1,<3.0.0)
|
15
15
|
Requires-Dist: loguru (>=0.7.0,<0.8.0)
|
@@ -1,10 +1,11 @@
|
|
1
1
|
sera/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
sera/constants.py,sha256=mzAaMyIx8TJK0-RYYJ5I24C4s0Uvj26OLMJmBo0pxHI,123
|
3
3
|
sera/exports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
sera/exports/schema.py,sha256=
|
4
|
+
sera/exports/schema.py,sha256=EAFKwULu8Nmb3IUBMCyt7M6YM4TGPo8yRRRu_KxBTxs,7045
|
5
5
|
sera/exports/test.py,sha256=jK1EJmLGiy7eREpnY_68IIVRH43uH8S_u5Z7STPbXOM,2002
|
6
6
|
sera/libs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
7
|
sera/libs/api_helper.py,sha256=47y1kcwk3Xd2ZEMnUj_0OwCuUmgwOs5kYrE95BDVUn4,5411
|
8
|
+
sera/libs/api_test_helper.py,sha256=3tRr8sLN4dBSrHgKAHMmyoENI0xh7K_JLel8AvujU7k,1323
|
8
9
|
sera/libs/base_orm.py,sha256=5hOH_diUeaABm3cpE2-9u50VRqG1QW2osPQnvVHIhIA,3365
|
9
10
|
sera/libs/base_service.py,sha256=AX1WoTHte6Z_birkkfagkNE6BrCLTlTjQE4jEsKEaAY,5152
|
10
11
|
sera/libs/dag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -15,8 +16,8 @@ sera/libs/middlewares/uscp.py,sha256=H5umW8iEQSCdb_MJ5Im49kxg1E7TpxSg1p2_2A5zI1U
|
|
15
16
|
sera/make/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
17
|
sera/make/__main__.py,sha256=bt-gDF8E026OWc2zqr9_a3paMOiDkFd3ybWn8ltL2g0,1448
|
17
18
|
sera/make/make_app.py,sha256=n9NtW73O3s_5Q31VHIRmnd-jEIcpDO7ksAsOdovde2s,5999
|
18
|
-
sera/make/make_python_api.py,sha256=
|
19
|
-
sera/make/make_python_model.py,sha256=
|
19
|
+
sera/make/make_python_api.py,sha256=9Pr5IxhC5iFkNq8DEh8Wfqa01AGm9YUxf9qdLmMdf_Y,26875
|
20
|
+
sera/make/make_python_model.py,sha256=Q9lirILQS_J00FfRBE8JZvMZTsfkZ6iziHknwxpU-fo,61420
|
20
21
|
sera/make/make_python_services.py,sha256=0ZpWLwQ7Nwfn8BXAikAB4JRpNknpSJyJgY5b1cjtxV4,2073
|
21
22
|
sera/make/make_typescript_model.py,sha256=ugDdSTw_1ayHLuL--92RQ8hf_D-dpJtnvmUZNxcwcDs,63687
|
22
23
|
sera/misc/__init__.py,sha256=Dh4uDq0D4N53h3zhvmwfa5a0TPVRSUvLzb0hkFuPirk,411
|
@@ -31,10 +32,10 @@ sera/models/_default.py,sha256=ABggW6qdPR4ZDqIPJdJ0GCGQ-7kfsfZmQ_DchgZEa-I,137
|
|
31
32
|
sera/models/_enum.py,sha256=sy0q7E646F-APsqrVQ52r1fAQ_DCAeaNq5YM5QN3zIk,2070
|
32
33
|
sera/models/_module.py,sha256=8QRSCubZmdDP9rL58rGAS6X5VCrkc1ZHvuMu1I1KrWk,5043
|
33
34
|
sera/models/_multi_lingual_string.py,sha256=JETN6k00VH4wrA4w5vAHMEJV8fp3SY9bJebskFTjQLA,1186
|
34
|
-
sera/models/_parse.py,sha256=
|
35
|
-
sera/models/_property.py,sha256=
|
35
|
+
sera/models/_parse.py,sha256=IhDBmtdwilI_SOV-Rj_DGiNUHTH_sE1s7-OhoGG0hK0,12154
|
36
|
+
sera/models/_property.py,sha256=yITA3B5lsFTMLMwXmIjPzEun_F_Z1NoRx9lroQEWXHM,7320
|
36
37
|
sera/models/_schema.py,sha256=VxJEiqgVvbXgcSUK4UW6JnRcggk4nsooVSE6MyXmfNY,1636
|
37
|
-
sera/typing.py,sha256=
|
38
|
-
sera_2-1.
|
39
|
-
sera_2-1.
|
40
|
-
sera_2-1.
|
38
|
+
sera/typing.py,sha256=Fl4-UzLJu1GdLLk_g87fA7nT9wQGelNnGzag6dg_0gs,980
|
39
|
+
sera_2-1.14.0.dist-info/METADATA,sha256=rcYbdo7hCFXIijgTD_qnQ8RAAEDTYpbZHvDHrEmicOU,867
|
40
|
+
sera_2-1.14.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
41
|
+
sera_2-1.14.0.dist-info/RECORD,,
|
File without changes
|