ns-orm 0.0.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.
- ns_orm/__init__.py +96 -0
- ns_orm/cli.py +174 -0
- ns_orm/database.py +292 -0
- ns_orm/dialects.py +290 -0
- ns_orm/exceptions.py +26 -0
- ns_orm/expressions.py +108 -0
- ns_orm/fields.py +313 -0
- ns_orm/manager.py +72 -0
- ns_orm/migrations/__init__.py +3 -0
- ns_orm/migrations/autodetector.py +159 -0
- ns_orm/migrations/executor.py +150 -0
- ns_orm/migrations/loader.py +53 -0
- ns_orm/migrations/migration.py +14 -0
- ns_orm/migrations/operations.py +93 -0
- ns_orm/migrations/state.py +42 -0
- ns_orm/migrations/writer.py +79 -0
- ns_orm/model.py +151 -0
- ns_orm/query.py +659 -0
- ns_orm/schema.py +131 -0
- ns_orm/typing.py +39 -0
- ns_orm/utils.py +58 -0
- ns_orm-0.0.0.dist-info/METADATA +289 -0
- ns_orm-0.0.0.dist-info/RECORD +27 -0
- ns_orm-0.0.0.dist-info/WHEEL +5 -0
- ns_orm-0.0.0.dist-info/entry_points.txt +2 -0
- ns_orm-0.0.0.dist-info/licenses/LICENSE +201 -0
- ns_orm-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from importlib.machinery import SourceFileLoader
|
|
5
|
+
from importlib.util import module_from_spec, spec_from_loader
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from ns_orm.migrations.migration import Migration
|
|
10
|
+
|
|
11
|
+
_MIGRATION_RE = re.compile(r"^\d{14}_.+\.py$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MigrationLoader:
|
|
15
|
+
def __init__(self, *, migrations_dir: Path):
|
|
16
|
+
self.migrations_dir = migrations_dir
|
|
17
|
+
|
|
18
|
+
def list_files(self) -> list[Path]:
|
|
19
|
+
if not self.migrations_dir.exists():
|
|
20
|
+
return []
|
|
21
|
+
files = [
|
|
22
|
+
p
|
|
23
|
+
for p in self.migrations_dir.iterdir()
|
|
24
|
+
if p.is_file() and _MIGRATION_RE.match(p.name)
|
|
25
|
+
]
|
|
26
|
+
return sorted(files, key=lambda p: p.name)
|
|
27
|
+
|
|
28
|
+
def load_all(self) -> list[Migration]:
|
|
29
|
+
migrations: list[Migration] = []
|
|
30
|
+
for p in self.list_files():
|
|
31
|
+
migrations.append(self.load_file(p))
|
|
32
|
+
return migrations
|
|
33
|
+
|
|
34
|
+
def load_file(self, path: Path) -> Migration:
|
|
35
|
+
loader = SourceFileLoader(f"ns_orm_user_migration_{path.stem}", str(path))
|
|
36
|
+
spec = spec_from_loader(loader.name, loader)
|
|
37
|
+
if spec is None:
|
|
38
|
+
raise RuntimeError(f"Failed to load migration: {path}")
|
|
39
|
+
mod = module_from_spec(spec)
|
|
40
|
+
assert spec.loader is not None
|
|
41
|
+
spec.loader.exec_module(mod)
|
|
42
|
+
mig = getattr(mod, "migration", None)
|
|
43
|
+
if not isinstance(mig, Migration):
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Migration file must define `migration: Migration`: {path}"
|
|
46
|
+
)
|
|
47
|
+
return mig
|
|
48
|
+
|
|
49
|
+
def latest_state(self) -> Optional[dict[str, Any]]:
|
|
50
|
+
migrations = self.load_all()
|
|
51
|
+
if not migrations:
|
|
52
|
+
return None
|
|
53
|
+
return migrations[-1].state
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ns_orm.migrations.operations import Operation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Migration:
|
|
11
|
+
name: str
|
|
12
|
+
operations: list[Operation]
|
|
13
|
+
state: dict[str, Any]
|
|
14
|
+
dependencies: list[str] = ()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ns_orm.dialects import Dialect
|
|
7
|
+
from ns_orm.exceptions import QueryError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fielddef_from_value(value: Any) -> Any:
|
|
11
|
+
if isinstance(value, dict) and "__class__" in value:
|
|
12
|
+
cls_name = value.get("__class__", "")
|
|
13
|
+
kwargs = {k: v for k, v in value.items() if k != "__class__"}
|
|
14
|
+
from ns_orm import fields as f
|
|
15
|
+
|
|
16
|
+
cls = getattr(f, cls_name, None)
|
|
17
|
+
if cls is None:
|
|
18
|
+
return f.Int()
|
|
19
|
+
try:
|
|
20
|
+
return cls(**kwargs)
|
|
21
|
+
except Exception:
|
|
22
|
+
return cls()
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Operation:
|
|
28
|
+
def sql(self, dialect: Dialect) -> list[str]:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CreateTable(Operation):
|
|
34
|
+
table_name: str
|
|
35
|
+
columns: list[tuple[str, Any]]
|
|
36
|
+
pk_name: str
|
|
37
|
+
fks: list[tuple[str, str, str, str]]
|
|
38
|
+
unique_together: list[tuple[str, ...]] = ()
|
|
39
|
+
|
|
40
|
+
def sql(self, dialect: Dialect) -> list[str]:
|
|
41
|
+
cols = [(name, _fielddef_from_value(fdef)) for name, fdef in self.columns]
|
|
42
|
+
ddl = dialect.ddl_create_table(
|
|
43
|
+
table_name=self.table_name,
|
|
44
|
+
columns=cols,
|
|
45
|
+
pk_name=self.pk_name,
|
|
46
|
+
fks=self.fks,
|
|
47
|
+
uniques=[],
|
|
48
|
+
unique_together=self.unique_together,
|
|
49
|
+
)
|
|
50
|
+
return [ddl]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class AddColumn(Operation):
|
|
55
|
+
table_name: str
|
|
56
|
+
column_name: str
|
|
57
|
+
column_def: Any
|
|
58
|
+
|
|
59
|
+
def sql(self, dialect: Dialect) -> list[str]:
|
|
60
|
+
col_def = _fielddef_from_value(self.column_def)
|
|
61
|
+
col = dialect.quote_ident(self.column_name)
|
|
62
|
+
ty = dialect.type_sql(col_def)
|
|
63
|
+
parts = [
|
|
64
|
+
f"ALTER TABLE {dialect.quote_ident(self.table_name)} ADD COLUMN {col} {ty}"
|
|
65
|
+
]
|
|
66
|
+
nullable = getattr(col_def, "nullable", None)
|
|
67
|
+
if nullable is False:
|
|
68
|
+
parts[0] += " NOT NULL"
|
|
69
|
+
if getattr(col_def, "unique", False):
|
|
70
|
+
parts[0] += " UNIQUE"
|
|
71
|
+
return parts
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class DropColumn(Operation):
|
|
76
|
+
table_name: str
|
|
77
|
+
column_name: str
|
|
78
|
+
|
|
79
|
+
def sql(self, dialect: Dialect) -> list[str]:
|
|
80
|
+
if dialect.name == "sqlite":
|
|
81
|
+
raise QueryError("SQLite 不支持直接 DROP COLUMN,请手工重建表或扩展迁移器")
|
|
82
|
+
return [
|
|
83
|
+
f"ALTER TABLE {dialect.quote_ident(self.table_name)} "
|
|
84
|
+
f"DROP COLUMN {dialect.quote_ident(self.column_name)}"
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class DropTable(Operation):
|
|
90
|
+
table_name: str
|
|
91
|
+
|
|
92
|
+
def sql(self, dialect: Dialect) -> list[str]:
|
|
93
|
+
return [f"DROP TABLE IF EXISTS {dialect.quote_ident(self.table_name)}"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, is_dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ns_orm.fields import ForeignKey, ManyToMany
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def field_to_dict(field: Any) -> dict[str, Any]:
|
|
10
|
+
if is_dataclass(field):
|
|
11
|
+
data = asdict(field)
|
|
12
|
+
else:
|
|
13
|
+
data = dict(getattr(field, "__dict__", {}))
|
|
14
|
+
data["__class__"] = field.__class__.__name__
|
|
15
|
+
return data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def model_state(model: type[Any]) -> dict[str, Any]:
|
|
19
|
+
fields: dict[str, Any] = {}
|
|
20
|
+
fks: dict[str, Any] = {}
|
|
21
|
+
m2m: dict[str, Any] = {}
|
|
22
|
+
|
|
23
|
+
for name, fdef in model._meta.fields.items():
|
|
24
|
+
if isinstance(fdef, ManyToMany):
|
|
25
|
+
m2m[name] = field_to_dict(fdef)
|
|
26
|
+
continue
|
|
27
|
+
if isinstance(fdef, ForeignKey):
|
|
28
|
+
fks[name] = field_to_dict(fdef)
|
|
29
|
+
fields[name] = field_to_dict(fdef)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"table_name": model.table_name(),
|
|
33
|
+
"schema": getattr(model._meta, "schema", None),
|
|
34
|
+
"pk_name": model.pk_name(),
|
|
35
|
+
"fields": fields,
|
|
36
|
+
"fks": fks,
|
|
37
|
+
"m2m": m2m,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def project_state(models: list[type[Any]]) -> dict[str, Any]:
|
|
42
|
+
return {m.__name__: model_state(m) for m in models}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ns_orm.migrations.migration import Migration
|
|
7
|
+
from ns_orm.migrations.operations import (
|
|
8
|
+
AddColumn,
|
|
9
|
+
CreateTable,
|
|
10
|
+
DropColumn,
|
|
11
|
+
DropTable,
|
|
12
|
+
Operation,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MigrationWriter:
|
|
17
|
+
def __init__(self, *, migrations_dir: Path):
|
|
18
|
+
self.migrations_dir = migrations_dir
|
|
19
|
+
|
|
20
|
+
def write(self, migration: Migration) -> Path:
|
|
21
|
+
self.migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
path = self.migrations_dir / f"{migration.name}.py"
|
|
23
|
+
content = self._render(migration)
|
|
24
|
+
path.write_text(content, encoding="utf-8")
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
def _render(self, migration: Migration) -> str:
|
|
28
|
+
ops_src = ",\n ".join(self._render_op(op) for op in migration.operations)
|
|
29
|
+
state_json = json.dumps(
|
|
30
|
+
migration.state, ensure_ascii=False, indent=2, sort_keys=True
|
|
31
|
+
)
|
|
32
|
+
return (
|
|
33
|
+
"from __future__ import annotations\n\n"
|
|
34
|
+
"from ns_orm.migrations.migration import Migration\n"
|
|
35
|
+
"from ns_orm.migrations.operations import (\n"
|
|
36
|
+
" AddColumn,\n"
|
|
37
|
+
" CreateTable,\n"
|
|
38
|
+
" DropColumn,\n"
|
|
39
|
+
" DropTable,\n"
|
|
40
|
+
")\n\n"
|
|
41
|
+
f"migration = Migration(\n"
|
|
42
|
+
f" name={migration.name!r},\n"
|
|
43
|
+
f" dependencies={list(migration.dependencies)!r},\n"
|
|
44
|
+
f" operations=[\n"
|
|
45
|
+
f" {ops_src}\n"
|
|
46
|
+
f" ],\n"
|
|
47
|
+
f" state={state_json},\n"
|
|
48
|
+
f")\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _render_op(self, op: Operation) -> str:
|
|
52
|
+
if isinstance(op, CreateTable):
|
|
53
|
+
return (
|
|
54
|
+
"CreateTable(\n"
|
|
55
|
+
f" table_name={op.table_name!r},\n"
|
|
56
|
+
f" columns={op.columns!r},\n"
|
|
57
|
+
f" pk_name={op.pk_name!r},\n"
|
|
58
|
+
f" fks={op.fks!r},\n"
|
|
59
|
+
f" unique_together={list(op.unique_together)!r},\n"
|
|
60
|
+
" )"
|
|
61
|
+
)
|
|
62
|
+
if isinstance(op, AddColumn):
|
|
63
|
+
return (
|
|
64
|
+
"AddColumn(\n"
|
|
65
|
+
f" table_name={op.table_name!r},\n"
|
|
66
|
+
f" column_name={op.column_name!r},\n"
|
|
67
|
+
f" column_def={op.column_def!r},\n"
|
|
68
|
+
" )"
|
|
69
|
+
)
|
|
70
|
+
if isinstance(op, DropColumn):
|
|
71
|
+
return (
|
|
72
|
+
"DropColumn(\n"
|
|
73
|
+
f" table_name={op.table_name!r},\n"
|
|
74
|
+
f" column_name={op.column_name!r},\n"
|
|
75
|
+
" )"
|
|
76
|
+
)
|
|
77
|
+
if isinstance(op, DropTable):
|
|
78
|
+
return f"DropTable(\n table_name={op.table_name!r},\n )"
|
|
79
|
+
raise TypeError(f"Unsupported operation: {op}")
|
ns_orm/model.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, ClassVar, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from ns_orm.exceptions import ModelDefinitionError
|
|
9
|
+
from ns_orm.fields import FieldDef, ForeignKey, ManyToMany
|
|
10
|
+
from ns_orm.typing import ModelMetaInfo
|
|
11
|
+
from ns_orm.utils import (
|
|
12
|
+
get_class_namespace,
|
|
13
|
+
get_type_hints_with_extras,
|
|
14
|
+
is_classvar,
|
|
15
|
+
resolve_ref,
|
|
16
|
+
unwrap_annotated,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_table_name(cls_name: str) -> str:
|
|
23
|
+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", cls_name)
|
|
24
|
+
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Model(BaseModel):
|
|
28
|
+
class Config:
|
|
29
|
+
extra = "allow"
|
|
30
|
+
validate_assignment = True
|
|
31
|
+
|
|
32
|
+
_meta: ClassVar[ModelMetaInfo]
|
|
33
|
+
_registry: ClassVar[dict[str, type[Model]]] = {}
|
|
34
|
+
objects: ClassVar[Any]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def table_name(cls) -> str:
|
|
38
|
+
return cls._meta.table_name
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def pk_name(cls) -> str:
|
|
42
|
+
return cls._meta.pk_name
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _resolve_model(cls, ref: str) -> type[Model]:
|
|
46
|
+
if ref in cls._registry:
|
|
47
|
+
return cls._registry[ref]
|
|
48
|
+
simple = ref.rsplit(".", 1)[-1]
|
|
49
|
+
if simple in cls._registry:
|
|
50
|
+
return cls._registry[simple]
|
|
51
|
+
raise ModelDefinitionError(f"Unresolved model reference: {ref}")
|
|
52
|
+
|
|
53
|
+
def pk_value(self) -> Any:
|
|
54
|
+
return getattr(self, self.pk_name())
|
|
55
|
+
|
|
56
|
+
def to_db_dict(self) -> dict[str, Any]:
|
|
57
|
+
data = self.dict()
|
|
58
|
+
cols = {k: v for k, v in data.items() if k in self._meta.fields}
|
|
59
|
+
return cols
|
|
60
|
+
|
|
61
|
+
def save(self, db: Any) -> Any:
|
|
62
|
+
from ns_orm.query import QuerySet
|
|
63
|
+
|
|
64
|
+
qs = QuerySet(self.__class__, db=db)
|
|
65
|
+
return qs.save_instance(self)
|
|
66
|
+
|
|
67
|
+
async def asave(self, db: Any) -> Any:
|
|
68
|
+
from ns_orm.query import AsyncQuerySet
|
|
69
|
+
|
|
70
|
+
qs = AsyncQuerySet(self.__class__, db=db)
|
|
71
|
+
return await qs.save_instance(self)
|
|
72
|
+
|
|
73
|
+
def delete(self, db: Any) -> int:
|
|
74
|
+
from ns_orm.query import QuerySet
|
|
75
|
+
|
|
76
|
+
qs = QuerySet(self.__class__, db=db)
|
|
77
|
+
return qs.filter(**{self.pk_name(): self.pk_value()}).delete()
|
|
78
|
+
|
|
79
|
+
async def adelete(self, db: Any) -> int:
|
|
80
|
+
from ns_orm.query import AsyncQuerySet
|
|
81
|
+
|
|
82
|
+
qs = AsyncQuerySet(self.__class__, db=db)
|
|
83
|
+
return await qs.filter(**{self.pk_name(): self.pk_value()}).delete()
|
|
84
|
+
|
|
85
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
86
|
+
super().__init_subclass__(**kwargs)
|
|
87
|
+
if cls.__name__ == "Model":
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
hints = get_type_hints_with_extras(cls)
|
|
91
|
+
namespace = get_class_namespace(cls)
|
|
92
|
+
|
|
93
|
+
fields: dict[str, FieldDef] = {}
|
|
94
|
+
relations: dict[str, tuple[str, ForeignKey]] = {}
|
|
95
|
+
m2m: dict[str, ManyToMany] = {}
|
|
96
|
+
|
|
97
|
+
for name, annotation in hints.items():
|
|
98
|
+
if is_classvar(annotation):
|
|
99
|
+
continue
|
|
100
|
+
base, meta = unwrap_annotated(annotation)
|
|
101
|
+
resolved_base = resolve_ref(base, namespace)
|
|
102
|
+
_ = resolved_base
|
|
103
|
+
for m in meta:
|
|
104
|
+
if isinstance(m, ForeignKey):
|
|
105
|
+
fields[name] = m
|
|
106
|
+
rel_name = name[:-3] if name.endswith("_id") else f"{name}_obj"
|
|
107
|
+
relations[rel_name] = (name, m)
|
|
108
|
+
elif isinstance(m, FieldDef):
|
|
109
|
+
fields[name] = m
|
|
110
|
+
elif isinstance(m, ManyToMany):
|
|
111
|
+
m2m[name] = m
|
|
112
|
+
|
|
113
|
+
if not fields:
|
|
114
|
+
raise ModelDefinitionError(
|
|
115
|
+
f"{cls.__name__} has no ORM fields. "
|
|
116
|
+
"Use typing.Annotated[..., FieldDef()]."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
table_name = getattr(
|
|
120
|
+
getattr(cls, "Meta", object), "table_name", None
|
|
121
|
+
) or _default_table_name(cls.__name__)
|
|
122
|
+
schema = getattr(getattr(cls, "Meta", object), "schema", None)
|
|
123
|
+
connect_name = getattr(getattr(cls, "Meta", object), "connect_name", None)
|
|
124
|
+
|
|
125
|
+
pk_candidates = [
|
|
126
|
+
n for n, f in fields.items() if getattr(f, "primary_key", False)
|
|
127
|
+
]
|
|
128
|
+
if pk_candidates:
|
|
129
|
+
pk_name = pk_candidates[0]
|
|
130
|
+
elif "id" in fields:
|
|
131
|
+
pk_name = "id"
|
|
132
|
+
else:
|
|
133
|
+
raise ModelDefinitionError(f"{cls.__name__} missing primary key field")
|
|
134
|
+
|
|
135
|
+
cls._meta = ModelMetaInfo(
|
|
136
|
+
table_name=table_name,
|
|
137
|
+
schema=schema,
|
|
138
|
+
connect_name=connect_name,
|
|
139
|
+
fields=fields,
|
|
140
|
+
pk_name=pk_name,
|
|
141
|
+
table=None,
|
|
142
|
+
metadata=None,
|
|
143
|
+
relations=relations,
|
|
144
|
+
m2m=m2m,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
cls._registry[cls.__name__] = cls
|
|
148
|
+
cls._registry[f"{cls.__module__}.{cls.__name__}"] = cls
|
|
149
|
+
from ns_orm.manager import Manager
|
|
150
|
+
|
|
151
|
+
cls.objects = Manager(model=cls)
|