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
ns_orm/dialects.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Iterable, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from ns_orm.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
_PARAM_RE = re.compile(r":([a-zA-Z_]\w*)")
|
|
10
|
+
_DIALECTS: Dict[str, Dialect] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PreparedSQL:
|
|
15
|
+
sql: str
|
|
16
|
+
params: Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Dialect:
|
|
21
|
+
name: str
|
|
22
|
+
paramstyle: str
|
|
23
|
+
quote_char: str = '"'
|
|
24
|
+
|
|
25
|
+
def quote_ident(self, ident: str) -> str:
|
|
26
|
+
q = self.quote_char
|
|
27
|
+
escaped = ident.replace(q, q + q)
|
|
28
|
+
return f"{q}{escaped}{q}"
|
|
29
|
+
|
|
30
|
+
def apply_limit_offset(
|
|
31
|
+
self, sql: str, limit: Optional[int], offset: Optional[int]
|
|
32
|
+
) -> str:
|
|
33
|
+
if limit is None and offset is None:
|
|
34
|
+
return sql
|
|
35
|
+
if limit is None:
|
|
36
|
+
return f"{sql} LIMIT -1 OFFSET {offset}"
|
|
37
|
+
if offset is None:
|
|
38
|
+
return f"{sql} LIMIT {limit}"
|
|
39
|
+
return f"{sql} LIMIT {limit} OFFSET {offset}"
|
|
40
|
+
|
|
41
|
+
def prepare(self, sql: str, params: Mapping[str, Any]) -> PreparedSQL:
|
|
42
|
+
style = self.paramstyle
|
|
43
|
+
if not params:
|
|
44
|
+
return PreparedSQL(
|
|
45
|
+
sql=sql, params=() if style in {"qmark", "format"} else {}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
keys_in_order = _PARAM_RE.findall(sql)
|
|
49
|
+
if not keys_in_order:
|
|
50
|
+
return PreparedSQL(sql=sql, params=params)
|
|
51
|
+
|
|
52
|
+
if style == "named":
|
|
53
|
+
return PreparedSQL(sql=sql, params=dict(params))
|
|
54
|
+
|
|
55
|
+
if style == "pyformat":
|
|
56
|
+
sql2 = _PARAM_RE.sub(lambda m: f"%({m.group(1)})s", sql)
|
|
57
|
+
return PreparedSQL(sql=sql2, params=dict(params))
|
|
58
|
+
|
|
59
|
+
if style == "qmark":
|
|
60
|
+
sql2 = _PARAM_RE.sub("?", sql)
|
|
61
|
+
return PreparedSQL(sql=sql2, params=tuple(params[k] for k in keys_in_order))
|
|
62
|
+
|
|
63
|
+
if style == "format":
|
|
64
|
+
sql2 = _PARAM_RE.sub("%s", sql)
|
|
65
|
+
return PreparedSQL(sql=sql2, params=tuple(params[k] for k in keys_in_order))
|
|
66
|
+
|
|
67
|
+
if style == "numeric":
|
|
68
|
+
mapping: dict[str, int] = {}
|
|
69
|
+
out_params: list[Any] = []
|
|
70
|
+
|
|
71
|
+
def _sub(m: re.Match[str]) -> str:
|
|
72
|
+
key = m.group(1)
|
|
73
|
+
if key not in mapping:
|
|
74
|
+
mapping[key] = len(out_params) + 1
|
|
75
|
+
out_params.append(params[key])
|
|
76
|
+
return f":{mapping[key]}"
|
|
77
|
+
|
|
78
|
+
sql2 = _PARAM_RE.sub(_sub, sql)
|
|
79
|
+
return PreparedSQL(sql=sql2, params=tuple(out_params))
|
|
80
|
+
|
|
81
|
+
raise ConfigurationError(f"Unsupported paramstyle: {style}")
|
|
82
|
+
|
|
83
|
+
def type_sql(self, field_def: Any) -> str:
|
|
84
|
+
render = getattr(field_def, "render_type", None)
|
|
85
|
+
if callable(render):
|
|
86
|
+
return str(render(self))
|
|
87
|
+
|
|
88
|
+
sa_type = getattr(field_def, "sa_type", None)
|
|
89
|
+
if sa_type is None:
|
|
90
|
+
raise ConfigurationError(f"Field has no type: {field_def}")
|
|
91
|
+
|
|
92
|
+
if sa_type == "VARCHAR":
|
|
93
|
+
max_length = getattr(field_def, "max_length", 255)
|
|
94
|
+
return f"VARCHAR({int(max_length)})"
|
|
95
|
+
if sa_type == "CHAR":
|
|
96
|
+
length = getattr(field_def, "length", 1)
|
|
97
|
+
return f"CHAR({int(length)})"
|
|
98
|
+
if sa_type == "BINARY":
|
|
99
|
+
length = getattr(field_def, "length", None)
|
|
100
|
+
return "BLOB" if length is None else f"BINARY({int(length)})"
|
|
101
|
+
if sa_type == "DECIMAL":
|
|
102
|
+
precision = getattr(field_def, "precision", 18)
|
|
103
|
+
scale = getattr(field_def, "scale", 6)
|
|
104
|
+
return f"DECIMAL({int(precision)},{int(scale)})"
|
|
105
|
+
if sa_type in {
|
|
106
|
+
"TINYINT",
|
|
107
|
+
"SMALLINT",
|
|
108
|
+
"INTEGER",
|
|
109
|
+
"BIGINT",
|
|
110
|
+
"REAL",
|
|
111
|
+
"DOUBLE",
|
|
112
|
+
"FLOAT",
|
|
113
|
+
"BOOLEAN",
|
|
114
|
+
"TEXT",
|
|
115
|
+
"DATE",
|
|
116
|
+
"JSON",
|
|
117
|
+
"UUID",
|
|
118
|
+
}:
|
|
119
|
+
return sa_type
|
|
120
|
+
if sa_type == "DATETIME":
|
|
121
|
+
return "TIMESTAMP"
|
|
122
|
+
|
|
123
|
+
return str(sa_type)
|
|
124
|
+
|
|
125
|
+
def ddl_create_table(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
table_name: str,
|
|
129
|
+
columns: list[tuple[str, Any]],
|
|
130
|
+
pk_name: str,
|
|
131
|
+
fks: list[tuple[str, str, str, str]],
|
|
132
|
+
uniques: Iterable[str],
|
|
133
|
+
unique_together: Iterable[tuple[str, ...]] = (),
|
|
134
|
+
) -> str:
|
|
135
|
+
qn = self.quote_ident(table_name)
|
|
136
|
+
col_sql: list[str] = []
|
|
137
|
+
for name, fdef in columns:
|
|
138
|
+
parts = [self.quote_ident(name), self.type_sql(fdef)]
|
|
139
|
+
if getattr(fdef, "primary_key", False):
|
|
140
|
+
parts.append("PRIMARY KEY")
|
|
141
|
+
nullable = getattr(fdef, "nullable", None)
|
|
142
|
+
if nullable is False:
|
|
143
|
+
parts.append("NOT NULL")
|
|
144
|
+
if getattr(fdef, "unique", False):
|
|
145
|
+
parts.append("UNIQUE")
|
|
146
|
+
col_sql.append(" ".join(parts))
|
|
147
|
+
|
|
148
|
+
for from_col, to_table, to_col, on_delete in fks:
|
|
149
|
+
col_sql.append(
|
|
150
|
+
" ".join(
|
|
151
|
+
[
|
|
152
|
+
"FOREIGN KEY",
|
|
153
|
+
f"({self.quote_ident(from_col)})",
|
|
154
|
+
"REFERENCES",
|
|
155
|
+
f"{self.quote_ident(to_table)}({self.quote_ident(to_col)})",
|
|
156
|
+
"ON DELETE",
|
|
157
|
+
on_delete,
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
for col in uniques:
|
|
163
|
+
col_sql.append(f"UNIQUE ({self.quote_ident(col)})")
|
|
164
|
+
|
|
165
|
+
for cols in unique_together:
|
|
166
|
+
col_sql.append(
|
|
167
|
+
"UNIQUE (" + ", ".join(self.quote_ident(c) for c in cols) + ")"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return f"CREATE TABLE IF NOT EXISTS {qn} (\n " + ",\n ".join(col_sql) + "\n)"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class ClickHouseDialect(Dialect):
|
|
175
|
+
def type_sql(self, field_def: Any) -> str:
|
|
176
|
+
render = getattr(field_def, "render_type", None)
|
|
177
|
+
if callable(render):
|
|
178
|
+
return str(render(self))
|
|
179
|
+
|
|
180
|
+
sa_type = getattr(field_def, "sa_type", None)
|
|
181
|
+
if sa_type is None:
|
|
182
|
+
raise ConfigurationError(f"Field has no type: {field_def}")
|
|
183
|
+
|
|
184
|
+
mapping = {
|
|
185
|
+
"TINYINT": "Int8",
|
|
186
|
+
"SMALLINT": "Int16",
|
|
187
|
+
"INTEGER": "Int32",
|
|
188
|
+
"BIGINT": "Int64",
|
|
189
|
+
"UTINYINT": "UInt8",
|
|
190
|
+
"USMALLINT": "UInt16",
|
|
191
|
+
"UINTEGER": "UInt32",
|
|
192
|
+
"UBIGINT": "UInt64",
|
|
193
|
+
"FLOAT": "Float32",
|
|
194
|
+
"REAL": "Float32",
|
|
195
|
+
"DOUBLE": "Float64",
|
|
196
|
+
"BOOLEAN": "Bool",
|
|
197
|
+
"TEXT": "String",
|
|
198
|
+
"VARCHAR": "String",
|
|
199
|
+
"CHAR": "String",
|
|
200
|
+
"DATE": "Date",
|
|
201
|
+
"DATETIME": "DateTime",
|
|
202
|
+
"UUID": "UUID",
|
|
203
|
+
"JSON": "JSON",
|
|
204
|
+
}
|
|
205
|
+
if sa_type == "DECIMAL":
|
|
206
|
+
precision = getattr(field_def, "precision", 18)
|
|
207
|
+
scale = getattr(field_def, "scale", 6)
|
|
208
|
+
return f"Decimal({int(precision)},{int(scale)})"
|
|
209
|
+
return mapping.get(str(sa_type), str(sa_type))
|
|
210
|
+
|
|
211
|
+
def ddl_create_table(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
table_name: str,
|
|
215
|
+
columns: list[tuple[str, Any]],
|
|
216
|
+
pk_name: str,
|
|
217
|
+
fks: list[tuple[str, str, str, str]],
|
|
218
|
+
uniques: Iterable[str],
|
|
219
|
+
unique_together: Iterable[tuple[str, ...]] = (),
|
|
220
|
+
) -> str:
|
|
221
|
+
qn = self.quote_ident(table_name)
|
|
222
|
+
col_sql: list[str] = []
|
|
223
|
+
for name, fdef in columns:
|
|
224
|
+
parts = [self.quote_ident(name), self.type_sql(fdef)]
|
|
225
|
+
nullable = getattr(fdef, "nullable", None)
|
|
226
|
+
if nullable is False:
|
|
227
|
+
parts.append("NOT NULL")
|
|
228
|
+
col_sql.append(" ".join(parts))
|
|
229
|
+
|
|
230
|
+
columns_ddl = ",\n ".join(col_sql)
|
|
231
|
+
return (
|
|
232
|
+
f"CREATE TABLE IF NOT EXISTS {qn} (\n {columns_ddl}\n)"
|
|
233
|
+
" ENGINE = MergeTree() ORDER BY tuple()"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
SQLITE_DIALECT = Dialect(name="sqlite", paramstyle="qmark", quote_char='"')
|
|
238
|
+
POSTGRES_DIALECT = Dialect(name="postgres", paramstyle="pyformat", quote_char='"')
|
|
239
|
+
MYSQL_DIALECT = Dialect(name="mysql", paramstyle="pyformat", quote_char="`")
|
|
240
|
+
MSSQL_DIALECT = Dialect(name="mssql", paramstyle="qmark", quote_char='"')
|
|
241
|
+
ORACLE_DIALECT = Dialect(name="oracle", paramstyle="named", quote_char='"')
|
|
242
|
+
DUCKDB_DIALECT = Dialect(name="duckdb", paramstyle="qmark", quote_char='"')
|
|
243
|
+
CLICKHOUSE_DIALECT = ClickHouseDialect(
|
|
244
|
+
name="clickhouse", paramstyle="pyformat", quote_char="`"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
_DIALECTS.update(
|
|
248
|
+
{
|
|
249
|
+
"sqlite": SQLITE_DIALECT,
|
|
250
|
+
"postgres": POSTGRES_DIALECT,
|
|
251
|
+
"postgresql": POSTGRES_DIALECT,
|
|
252
|
+
"mysql": MYSQL_DIALECT,
|
|
253
|
+
"mssql": MSSQL_DIALECT,
|
|
254
|
+
"sqlserver": MSSQL_DIALECT,
|
|
255
|
+
"oracle": ORACLE_DIALECT,
|
|
256
|
+
"duckdb": DUCKDB_DIALECT,
|
|
257
|
+
"clickhouse": CLICKHOUSE_DIALECT,
|
|
258
|
+
"ch": CLICKHOUSE_DIALECT,
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def register_dialect(name: str, dialect: Dialect) -> None:
|
|
264
|
+
_DIALECTS[name.lower()] = dialect
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_dialect(name: str) -> Dialect:
|
|
268
|
+
key = name.lower()
|
|
269
|
+
if key in _DIALECTS:
|
|
270
|
+
return _DIALECTS[key]
|
|
271
|
+
return Dialect(name=key, paramstyle="named", quote_char='"')
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _normalize_scheme(scheme: str) -> str:
|
|
275
|
+
scheme = scheme.strip().lower()
|
|
276
|
+
if "+" in scheme:
|
|
277
|
+
scheme = scheme.split("+", 1)[0]
|
|
278
|
+
return scheme
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def dialect_from_url(scheme: str) -> Dialect:
|
|
282
|
+
scheme = _normalize_scheme(scheme)
|
|
283
|
+
if scheme.startswith(("mariadb", "tidb")):
|
|
284
|
+
scheme = "mysql"
|
|
285
|
+
if scheme.startswith(("cockroachdb", "redshift")):
|
|
286
|
+
scheme = "postgres"
|
|
287
|
+
for key, d in _DIALECTS.items():
|
|
288
|
+
if scheme.startswith(key):
|
|
289
|
+
return d
|
|
290
|
+
return get_dialect(scheme)
|
ns_orm/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class OrmError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(OrmError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ModelDefinitionError(OrmError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueryError(OrmError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DoesNotExist(OrmError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MultipleObjectsReturned(OrmError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IntegrityError(OrmError):
|
|
26
|
+
pass
|
ns_orm/expressions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Iterable, List, Mapping, Tuple, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Condition:
|
|
9
|
+
sql: str
|
|
10
|
+
params: Mapping[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _merge_params(*parts: Mapping[str, Any]) -> dict[str, Any]:
|
|
14
|
+
merged: dict[str, Any] = {}
|
|
15
|
+
for p in parts:
|
|
16
|
+
for k, v in p.items():
|
|
17
|
+
if k in merged:
|
|
18
|
+
raise ValueError(f"Duplicate param: {k}")
|
|
19
|
+
merged[k] = v
|
|
20
|
+
return merged
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Q:
|
|
24
|
+
__slots__ = ("_op", "_children", "_negated")
|
|
25
|
+
|
|
26
|
+
def __init__(self, **lookups: Any):
|
|
27
|
+
self._op: str = "AND"
|
|
28
|
+
self._children: List[Union[Q, Tuple[str, Any]]] = []
|
|
29
|
+
self._negated: bool = False
|
|
30
|
+
for k, v in lookups.items():
|
|
31
|
+
self._children.append((k, v))
|
|
32
|
+
|
|
33
|
+
def __and__(self, other: Q) -> Q:
|
|
34
|
+
return self._combine("AND", other)
|
|
35
|
+
|
|
36
|
+
def __or__(self, other: Q) -> Q:
|
|
37
|
+
return self._combine("OR", other)
|
|
38
|
+
|
|
39
|
+
def __invert__(self) -> Q:
|
|
40
|
+
q = Q()
|
|
41
|
+
q._op = self._op
|
|
42
|
+
q._children = list(self._children)
|
|
43
|
+
q._negated = not self._negated
|
|
44
|
+
return q
|
|
45
|
+
|
|
46
|
+
def _combine(self, op: str, other: Q) -> Q:
|
|
47
|
+
q = Q()
|
|
48
|
+
q._op = op
|
|
49
|
+
q._children = [self, other]
|
|
50
|
+
return q
|
|
51
|
+
|
|
52
|
+
def is_empty(self) -> bool:
|
|
53
|
+
return not self._children
|
|
54
|
+
|
|
55
|
+
def compile(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
table_alias: str,
|
|
59
|
+
param_prefix: str,
|
|
60
|
+
start_index: int,
|
|
61
|
+
lookup_compiler: Any,
|
|
62
|
+
) -> tuple[Condition, int]:
|
|
63
|
+
if self.is_empty():
|
|
64
|
+
return Condition(sql="1=1", params={}), start_index
|
|
65
|
+
|
|
66
|
+
parts: list[str] = []
|
|
67
|
+
params: dict[str, Any] = {}
|
|
68
|
+
idx = start_index
|
|
69
|
+
|
|
70
|
+
for child in self._children:
|
|
71
|
+
if isinstance(child, Q):
|
|
72
|
+
cond, idx = child.compile(
|
|
73
|
+
table_alias=table_alias,
|
|
74
|
+
param_prefix=param_prefix,
|
|
75
|
+
start_index=idx,
|
|
76
|
+
lookup_compiler=lookup_compiler,
|
|
77
|
+
)
|
|
78
|
+
parts.append(f"({cond.sql})")
|
|
79
|
+
params = _merge_params(params, cond.params)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
lookup, value = child
|
|
83
|
+
cond, idx = lookup_compiler(
|
|
84
|
+
lookup=lookup,
|
|
85
|
+
value=value,
|
|
86
|
+
table_alias=table_alias,
|
|
87
|
+
param_prefix=param_prefix,
|
|
88
|
+
start_index=idx,
|
|
89
|
+
)
|
|
90
|
+
parts.append(cond.sql)
|
|
91
|
+
params = _merge_params(params, cond.params)
|
|
92
|
+
|
|
93
|
+
combined = f" {self._op} ".join(parts)
|
|
94
|
+
if self._negated:
|
|
95
|
+
combined = f"NOT ({combined})"
|
|
96
|
+
return Condition(sql=combined, params=params), idx
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_in_values(value: Any) -> list[Any]:
|
|
100
|
+
if value is None:
|
|
101
|
+
return []
|
|
102
|
+
if isinstance(value, (str, bytes)):
|
|
103
|
+
return [value]
|
|
104
|
+
if isinstance(value, Mapping):
|
|
105
|
+
return list(value.values())
|
|
106
|
+
if isinstance(value, Iterable):
|
|
107
|
+
return list(value)
|
|
108
|
+
return [value]
|