slinn-orm 0.0.3__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: slinn-orm
3
+ Version: 0.0.3
4
+ Summary: ORM for Slinn
5
+ Author-email: Mark Radin <openmiot@gmail.com>
6
+ Requires-Python: >=3.14
7
+ License-File: LICENSE
8
+ Provides-Extra: postgres
9
+ Requires-Dist: asyncpg; extra == "postgres"
10
+ Provides-Extra: sqlite
11
+ Requires-Dist: aiosqlite; extra == "sqlite"
12
+ Provides-Extra: full
13
+ Requires-Dist: slinn-orm[postgres]; extra == "full"
14
+ Requires-Dist: slinn-orm[sqlite]; extra == "full"
15
+ Dynamic: license-file
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol, Coroutine
3
+ from .order import Order
4
+ from .models import Model
5
+ import re
6
+
7
+
8
+ DSN_PATTERN = re.compile(r'^(\w+):(\/\/)?\w*:?.*@?.+(\/)?\w+')
9
+
10
+
11
+ class CollectionProtocol(Protocol):
12
+ async def find(self, _filter: dict, *, fields: tuple[str] = ('*',)) -> list[dict]: ...
13
+ async def find_one(self, _filter: dict, *, fields: tuple[str] = ('*',)) -> dict: ...
14
+ async def insert(self, _object: dict, *, returning: tuple[str] = (), typemap: dict | None = None) -> None: ...
15
+ async def update(self, _filter: dict, _object: dict, *, returning: tuple[str] = (), typemap: dict | None = None) -> None: ...
16
+ async def delete(self, _filter) -> None: ...
17
+ async def pop(self, _filter) -> dict: ...
18
+ async def drop(self, *, not_exists_ok: bool = False) -> None: ...
19
+ async def count(self, _filter: dict) -> int: ...
20
+ async def get_size(self) -> int: ...
21
+
22
+
23
+ class ConnectionProtocol(Protocol):
24
+ async def __aenter__(self) -> ConnectionProtocol: ...
25
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
26
+ def __getattr__(self, key) -> CollectionProtocol: ...
27
+ def collection(self, key) -> CollectionProtocol: ...
28
+ async def commit(self) -> None: ...
29
+ async def rollback(self) -> None: ...
30
+ async def collections(self) -> tuple[CollectionProtocol]: ...
31
+ async def create(self, model: Model, *, exists_ok=False) -> None: ...
32
+ async def create_index(
33
+ self,
34
+ index_name: str,
35
+ collection_name: str,
36
+ fields: tuple[str],
37
+ *,
38
+ exists_ok: bool=False,
39
+ unique: bool=False
40
+ ) -> None: ...
41
+
42
+
43
+ class PoolProtocol(Protocol):
44
+ async def acquire(self) -> ConnectionProtocol: ...
45
+ async def release(self, connection: ConnectionProtocol) -> None: ...
46
+ def on_acquire(self, coroutine: Coroutine[ConnectionProtocol]) -> None: ...
47
+
48
+
49
+ def get_driver_name(dsn: str) -> str:
50
+ return DSN_PATTERN.findall(dsn)[0][0].lower()
@@ -0,0 +1,9 @@
1
+ class AttributedDict(dict):
2
+ def __init__(self, *args, **kwargs):
3
+ if args[0] is None:
4
+ self = None
5
+ else:
6
+ dict.__init__(self, *args, **kwargs)
7
+
8
+ def __getattr__(self, item):
9
+ return self[item]
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from typing import Literal, Optional
4
+ from datetime import timedelta, datetime
5
+ from typing import NamedTuple
6
+
7
+
8
+ class Model(ABC):
9
+ @staticmethod
10
+ @abstractmethod
11
+ def __collection_name__() -> str: ...
12
+
13
+ def _update_meta(self) -> None:
14
+ if not hasattr(self, '_meta_primary_keys'):
15
+ self._meta_primary_keys = ()
16
+
17
+ for field_name in self.__dict__.keys():
18
+ field = self.__dict__[field_name]
19
+ if field_name.startswith('_') or not isinstance(field, Field):
20
+ continue
21
+ if hasattr(field, 'primary') and field.primary:
22
+ if field_name not in self._meta_primary_keys:
23
+ self._meta_primary_keys = (*self._meta_primary_keys, field_name)
24
+
25
+
26
+ class Field(ABC):
27
+ class FieldReference(NamedTuple):
28
+ collection_name: str
29
+ field_name: str
30
+
31
+ def make_identity(self) -> Field:
32
+ self.identity = True
33
+ return self
34
+
35
+ def make_primary(self) -> Field:
36
+ self.primary = True
37
+ return self
38
+
39
+ def make_unique(self) -> Field:
40
+ self.unique = True
41
+ return self
42
+
43
+ def make_not_null(self) -> Field:
44
+ self.not_null = True
45
+ return self
46
+
47
+ def set_reference(self, collection_name: str, field_name: str) -> Field:
48
+ self.reference = self.FieldReference(collection_name, field_name)
49
+ return self
50
+
51
+
52
+ class BoolField(Field): ...
53
+
54
+
55
+ class IntegerField(Field):
56
+ def __init__(self, *, size: Literal[2, 4, 8] = 4):
57
+ self.size = size
58
+
59
+
60
+ class DecimalField(Field): ...
61
+
62
+
63
+ class FloatField(Field):
64
+ def __init__(self, *, double: bool = False):
65
+ self.double = double
66
+
67
+
68
+ class TextField(Field): ...
69
+
70
+
71
+ class TimeField(Field):
72
+ def __init__(
73
+ self,
74
+ *,
75
+ zone: bool = True,
76
+ date: bool = True,
77
+ time: bool = True,
78
+ default: Optional[timedelta | datetime]
79
+ ):
80
+ self.zone = zone
81
+ self.date = date
82
+ self.time = time
83
+ self.default = default
84
+
85
+
86
+ class TimeDeltaField(Field): ...
87
+
88
+
89
+ class BinaryField(Field): ...
90
+
91
+
92
+ class UIDField(Field):
93
+ def __init__(self):
94
+ self.make_identity()
95
+
96
+
97
+ class UUIDField(Field): ...
@@ -0,0 +1,16 @@
1
+ import enum
2
+
3
+
4
+ class OrderType(enum.Enum):
5
+ ASC = 0
6
+ DESC = 1
7
+
8
+
9
+ class Order:
10
+ @staticmethod
11
+ def ASC(field: str) -> tuple[OrderType, str]:
12
+ return (OrderType.ASC, field)
13
+
14
+ @staticmethod
15
+ def DESC(field: str) -> tuple[OrderType, str]:
16
+ return (OrderType.DESC, field)
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+ from .. import CollectionProtocol, ConnectionProtocol, PoolProtocol
3
+ from ..attributed_dict import AttributedDict
4
+ from ..typemap import typemap as tm
5
+ from ..order import OrderType
6
+ from ..models import (Model, Field, BoolField, IntegerField, DecimalField, FloatField, TextField, TimeField, TimeDeltaField,
7
+ BinaryField, UIDField, UUIDField)
8
+ from typing import Coroutine, Optional, Final
9
+ from functools import partial
10
+ from datetime import datetime, timedelta
11
+ import asyncpg
12
+
13
+
14
+ class PostgresCollection(CollectionProtocol):
15
+ def __init__(self, connection: PostgresConnection, name: str):
16
+ self.connection = connection
17
+ self.name = name
18
+
19
+ async def find(
20
+ self,
21
+ _filter: dict,
22
+ *,
23
+ fields: tuple[str] = ('*',),
24
+ order: tuple[tuple[OrderType, str]] = ()) -> list[dict]:
25
+ return [AttributedDict(record) for record in await self.connection._fetch(
26
+ f'SELECT {', '.join(fields)} '
27
+ f'FROM {self.name} '
28
+ f'{'WHERE ' if _filter else ''}{' AND '.join([f'{list(_filter.keys())[i]}=${i+1}' for i in range(0, len(_filter))])} '
29
+ f'{'ORDER BY ' if order else ''}{', '.join([(f'{o[1]} {'ASC' if o[0] is OrderType.ASC else 'DESC'}') for o in order])};',
30
+ *_filter.values())]
31
+
32
+ async def find_one(
33
+ self,
34
+ _filter: dict,
35
+ *,
36
+ fields: tuple[str] = ('*',)) -> dict:
37
+ result = await self.find(
38
+ _filter,
39
+ fields=fields
40
+ )
41
+ return result[0] if result else None
42
+
43
+ async def insert(
44
+ self,
45
+ _object: dict,
46
+ *,
47
+ returning: tuple[str] = (),
48
+ typemap: dict | None = None) -> None:
49
+ if not _object:
50
+ return []
51
+ records = await self.connection._fetch(
52
+ f'INSERT INTO {self.name}({', '.join(_object.keys())}) '
53
+ f'VALUES({', '.join([f'${i+1}' for i in range(0, len(_object))])}) '
54
+ f'{'RETURNING' if returning else ''} {', '.join(returning)}',
55
+ *tm(_object, typemap).values())
56
+ return records[0] if records else None
57
+
58
+ async def update(
59
+ self,
60
+ _filter: dict,
61
+ _object: dict,
62
+ *,
63
+ returning: tuple[str] = (),
64
+ typemap: dict | None = None) -> None:
65
+ if not _object:
66
+ return []
67
+ return await self.connection._fetch(
68
+ f'UPDATE {self.name} '
69
+ f'SET {', '.join([f'{key}=${i+1}' for i, key in enumerate(_object)])} '
70
+ f'{'WHERE ' if _filter else ''}{' AND '.join([f'{list(_filter.keys())[i]}=${i+len(_object)+1}' for i in range(0, len(_filter))])} '
71
+ f'{'RETURNING' if returning else ''} {', '.join(returning)};',
72
+ *tm(_object, typemap).values(), *_filter.values())
73
+
74
+ async def delete(self, _filter: dict) -> None:
75
+ await self.connection._fetch(f'''
76
+ DELETE FROM {self.name}
77
+ {'WHERE ' if _filter else ''}{' AND '.join([f'{list(_filter.keys())[i]}=${i+1}' for i in range(0, len(_filter))])};''',
78
+ *_filter.values())
79
+
80
+ async def pop(
81
+ self,
82
+ _filter: dict,
83
+ *,
84
+ returning: tuple[str] = ('*', )) -> dict:
85
+ return await self.connection._fetch(
86
+ f'DELETE FROM {self.name} '
87
+ f'{'WHERE ' if _filter else ''}{' AND '.join([f'{list(_filter.keys())[i]}=${i+1}' for i in range(0, len(_filter))])} '
88
+ f'{'RETURNING' if returning else ''} {', '.join(returning)};',
89
+ *_filter.values())
90
+
91
+ async def count(self, _filter: dict) -> int:
92
+ return (await self.connection._fetch(f'''
93
+ SELECT COUNT(*)
94
+ FROM {self.name}
95
+ {'WHERE ' if _filter else ''}{' AND '.join([f'{list(_filter.keys())[i]}=${i+1}' for i in range(0, len(_filter))])};''',
96
+ *_filter.values()))[0]['count']
97
+ async def get_size(self) -> int: ...
98
+
99
+ async def drop(self, *, not_exists_ok: bool = False) -> None:
100
+ await self.connection._fetch(f'DROP TABLE {'IF EXISTS ' if not_exists_ok else ''}{self.name};')
101
+
102
+
103
+ class PostgresConnection(ConnectionProtocol):
104
+ FIELDS_MAP: Final[dict] = {
105
+ BoolField: lambda field: 'BOOLEAN',
106
+ IntegerField: lambda field: {
107
+ 2: 'SMALLINT',
108
+ 4: 'INTEGER',
109
+ 8: 'BIGINT'
110
+ }[field.size],
111
+ DecimalField: lambda field: 'DECIMAL',
112
+ FloatField: lambda field: 'DOUBLE PRECISION' if field.double else 'REAL',
113
+ TextField: lambda field: 'TEXT',
114
+ TimeField: lambda field: {
115
+ (False, False, True): 'TIME',
116
+ (False, True, False): 'DATE',
117
+ (False, True, True): 'TIMESTAMP',
118
+ (True, False, True): 'TIMETZ',
119
+ (True, True, False): 'DATE',
120
+ (True, True, True): 'TIMESTAMPTZ'
121
+ }[field.zone, field.date, field.time] + (
122
+ '' if field.default is None else (' DEFAULT ' + {
123
+ datetime: lambda dt: f'to_timestamp({dt.timestamp()})',
124
+ timedelta: lambda td: f'NOW() + INTERVAL \'{td.total_seconds()} seconds\''
125
+ }[type(field.default)](field.default))
126
+ ),
127
+ TimeDeltaField: lambda field: 'INTERVAL',
128
+ BinaryField: lambda field: 'BYTEA',
129
+ UIDField: lambda field: 'BIGINT',
130
+ UUIDField: lambda field: 'UUID'
131
+ }
132
+
133
+ def __init__(self, pool, connection, transaction, autocommit=True):
134
+ self._pool = pool
135
+ self._connection = connection
136
+ self._transaction = transaction
137
+ self._autocommit = autocommit
138
+ self.collection = self.__getattr__
139
+
140
+ def __getattr__(self, key) -> CollectionProtocol:
141
+ return PostgresCollection(self, key)
142
+
143
+ async def rollback(self) -> None:
144
+ await self._transaction.rollback()
145
+
146
+ async def commit(self) -> None:
147
+ await self._transaction.commit()
148
+
149
+ async def collections(self) -> list:
150
+ return [collection[0] for collection in await self._fetch('''
151
+ SELECT table_name
152
+ FROM information_schema.tables
153
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_type = 'BASE TABLE'
154
+ ORDER BY table_name;''')]
155
+
156
+ async def create(self, model: Model, *, exists_ok=False) -> None:
157
+ model._update_meta(model)
158
+ fields = []
159
+ for field_name in model.__dict__.keys():
160
+ field = getattr(model, field_name)
161
+ if field_name.startswith('_') or not isinstance(field, Field):
162
+ continue
163
+ fields.append(
164
+ f'''
165
+ {field_name} {self.FIELDS_MAP[type(field)](field)}
166
+ {'UNIQUE' if hasattr(field, 'unique') and field.unique else ''}
167
+ {'NOT NULL' if hasattr(field, 'not_null') and field.not_null else ''}
168
+ {'GENERATED ALWAYS AS IDENTITY' if hasattr(field, 'identity') and field.identity else ''}
169
+ {f'REFERENCES {field.reference.collection_name}({field.reference.field_name})'
170
+ if hasattr(field, 'reference') and field.reference else ''}
171
+ '''.strip()
172
+ )
173
+ await self._fetch(f'''
174
+ CREATE TABLE {'IF NOT EXISTS ' if exists_ok else ''}{model.__collection_name__()}(
175
+ {','.join(fields)},
176
+ {f'PRIMARY KEY ({','.join(model._meta_primary_keys)})' if model._meta_primary_keys else ''}
177
+ );
178
+ '''.strip())
179
+
180
+ async def create_index(
181
+ self,
182
+ index_name: str,
183
+ collection_name: str,
184
+ fields: tuple[str],
185
+ *,
186
+ exists_ok: bool=False,
187
+ unique: bool=False
188
+ ) -> None:
189
+ print(f"""
190
+ CREATE {'UNIQUE ' if unique else ''}INDEX {'IF NOT EXISTS ' if exists_ok else ''}{index_name} ON {collection_name}({','.join(fields)});
191
+ """)
192
+ await self._fetch(f'''
193
+ CREATE {'UNIQUE ' if unique else ''}INDEX {'IF NOT EXISTS ' if exists_ok else ''}{index_name} ON {collection_name}({','.join(fields)});
194
+ ''')
195
+
196
+ async def __aenter__(self):
197
+ await self._transaction.start()
198
+ return self
199
+
200
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
201
+ try:
202
+ if not exc_type and self._autocommit:
203
+ await self.commit()
204
+ else:
205
+ await self.rollback()
206
+ finally:
207
+ await self._pool.release(self)
208
+
209
+ async def _fetch(self, query, *args, timeout: float | None = None, record_class=None) -> list:
210
+ query = ' '.join([line.strip() for line in query.split('\n')]).strip()
211
+ return await self._connection.fetch(
212
+ query,
213
+ *args,
214
+ timeout=timeout,
215
+ record_class=record_class
216
+ )
217
+
218
+ class PostgresPool(PoolProtocol):
219
+ def __init__(self, pool_factory, autocommit=True):
220
+ self._pool_factory = pool_factory
221
+ self._autocommit = autocommit
222
+ self._pool = None
223
+ self._tasks = []
224
+
225
+ async def acquire(self) -> PostgresConnection:
226
+ if not self._pool:
227
+ self._pool = await self._pool_factory()
228
+ connection = await self._pool.acquire()
229
+ transaction = connection.transaction()
230
+ pg_conn = PostgresConnection(self, connection, transaction)
231
+ tasks, self._tasks = self._tasks, []
232
+ for task in tasks:
233
+ await task(pg_conn)
234
+ return pg_conn
235
+
236
+ async def release(self, connection: PostgresConnection):
237
+ await self._pool.release(connection._connection)
238
+ del connection
239
+
240
+ def on_acquire(self, coroutine: Coroutine[ConnectionProtocol]) -> None:
241
+ self._tasks.append(coroutine)
242
+
243
+ async def close(self):
244
+ self._pool.close()
245
+
246
+
247
+ def Postgres(
248
+ dsn: Optional[str] = None,
249
+ *,
250
+ host: Optional[str] = None,
251
+ port: Optional[int] = None,
252
+ user: Optional[str] = None,
253
+ password: Optional[str] = None,
254
+ database: Optional[str] = None,
255
+ server_settings: Optional[dict] = None,
256
+ autocommit: bool = True) -> PostgresPool:
257
+ return PostgresPool(
258
+ partial(
259
+ asyncpg.create_pool,
260
+ dsn,
261
+ server_settings=server_settings
262
+ ) if dsn else partial(
263
+ asyncpg.create_pool,
264
+ host=host,
265
+ port=port,
266
+ user=user,
267
+ password=password,
268
+ database=database,
269
+ server_settings=server_settings
270
+ ),
271
+ autocommit=autocommit
272
+ )
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+ from functools import partial
3
+ from typing import Any, Coroutine, Optional, Final
4
+ from .. import CollectionProtocol, ConnectionProtocol, PoolProtocol
5
+ from ..attributed_dict import AttributedDict
6
+ from ..typemap import typemap as tm
7
+ from ..order import OrderType
8
+ from ..models import (Model, Field, BoolField, IntegerField, DecimalField, FloatField, TextField, TimeField, TimeDeltaField,
9
+ BinaryField, UIDField, UUIDField)
10
+ from datetime import datetime, timedelta
11
+ import aiosqlite
12
+ import asyncio
13
+
14
+
15
+ class SQLiteCollection(CollectionProtocol):
16
+ def __init__(self, connection: SQLiteConnection, name: str):
17
+ self.connection = connection
18
+ self.name = name
19
+
20
+ async def find(
21
+ self,
22
+ _filter: dict,
23
+ *,
24
+ fields: tuple[str] = ("*",),
25
+ order: tuple[tuple[OrderType, str]] = (),
26
+ ) -> list[dict]:
27
+ where_clause = ""
28
+ params = []
29
+ if _filter:
30
+ conditions = [f"{key} = ?" for key in _filter.keys()]
31
+ where_clause = f"WHERE {' AND '.join(conditions)}"
32
+ params.extend(_filter.values())
33
+
34
+ order_clause = ""
35
+ if order:
36
+ order_parts = [
37
+ f"{col} {'ASC' if dir == OrderType.ASC else 'DESC'}"
38
+ for dir, col in order
39
+ ]
40
+ order_clause = f"ORDER BY {', '.join(order_parts)}"
41
+
42
+ query = f"SELECT {', '.join(fields)} FROM {self.name} {where_clause} {order_clause}"
43
+ rows = await self.connection._fetch(query, *params)
44
+ return [AttributedDict(row) for row in rows]
45
+
46
+ async def find_one(
47
+ self, _filter: dict, *, fields: tuple[str] = ("*",)
48
+ ) -> Optional[dict]:
49
+ result = await self.find(_filter, fields=fields)
50
+ return result[0] if result else None
51
+
52
+ async def insert(
53
+ self,
54
+ _object: dict,
55
+ *,
56
+ returning: tuple[str] = (),
57
+ typemap: Optional[dict] = None,
58
+ ) -> Optional[dict]:
59
+ if not _object:
60
+ return None
61
+
62
+ data = tm(_object, typemap)
63
+ columns = ", ".join(data.keys())
64
+ placeholders = ", ".join(["?"] * len(data))
65
+ returning_clause = ""
66
+ if returning:
67
+ returning_clause = f"RETURNING {', '.join(returning)}"
68
+
69
+ query = f"INSERT INTO {self.name}({columns}) VALUES({placeholders}) {returning_clause}"
70
+ rows = await self.connection._fetch(query, *data.values())
71
+ return rows[0] if rows else None
72
+
73
+ async def update(
74
+ self,
75
+ _filter: dict,
76
+ _object: dict,
77
+ *,
78
+ returning: tuple[str] = (),
79
+ typemap: Optional[dict] = None,
80
+ ) -> list[dict]:
81
+ if not _object:
82
+ return []
83
+
84
+ data = tm(_object, typemap)
85
+ set_clause = ", ".join([f"{key} = ?" for key in data.keys()])
86
+ params = list(data.values())
87
+
88
+ where_clause = ""
89
+ if _filter:
90
+ conditions = [f"{key} = ?" for key in _filter.keys()]
91
+ where_clause = f"WHERE {' AND '.join(conditions)}"
92
+ params.extend(_filter.values())
93
+
94
+ returning_clause = ""
95
+ if returning:
96
+ returning_clause = f"RETURNING {', '.join(returning)}"
97
+
98
+ query = f"UPDATE {self.name} SET {set_clause} {where_clause} {returning_clause}"
99
+ return await self.connection._fetch(query, *params)
100
+
101
+ async def delete(self, _filter: dict) -> None:
102
+ where_clause = ""
103
+ params = []
104
+ if _filter:
105
+ conditions = [f"{key} = ?" for key in _filter.keys()]
106
+ where_clause = f"WHERE {' AND '.join(conditions)}"
107
+ params.extend(_filter.values())
108
+
109
+ query = f"DELETE FROM {self.name} {where_clause}"
110
+ await self.connection._execute(query, *params)
111
+
112
+ async def pop(
113
+ self, _filter: dict, *, returning: tuple[str] = ("*",)
114
+ ) -> Optional[dict]:
115
+ where_clause = ""
116
+ params = []
117
+ if _filter:
118
+ conditions = [f"{key} = ?" for key in _filter.keys()]
119
+ where_clause = f"WHERE {' AND '.join(conditions)}"
120
+ params.extend(_filter.values())
121
+
122
+ returning_clause = f"RETURNING {', '.join(returning)}" if returning else ""
123
+ query = f"DELETE FROM {self.name} {where_clause} {returning_clause}"
124
+ rows = await self.connection._fetch(query, *params)
125
+ return rows[0] if rows else None
126
+
127
+ async def count(self, _filter: dict) -> int:
128
+ where_clause = ""
129
+ params = []
130
+ if _filter:
131
+ conditions = [f"{key} = ?" for key in _filter.keys()]
132
+ where_clause = f"WHERE {' AND '.join(conditions)}"
133
+ params.extend(_filter.values())
134
+
135
+ query = f"SELECT COUNT(*) FROM {self.name} {where_clause}"
136
+ rows = await self.connection._fetch(query, *params)
137
+ return rows[0][0] if rows else 0
138
+
139
+ async def get_size(self) -> int:
140
+ rows = await self.connection._fetch(
141
+ f'''
142
+ SELECT COUNT(*) * pgsize AS table_size_bytes
143
+ FROM sqlite_dbpage, (SELECT page_size AS pgsize FROM pragma_page_size())
144
+ WHERE schema = 'main' AND pgno IN (
145
+ SELECT rootpage FROM sqlite_schema WHERE tbl_name = '{self.name}'
146
+ );
147
+ '''
148
+ )
149
+ return rows[0] if rows else 0
150
+
151
+ async def drop(self, *, not_exists_ok: bool = False) -> None:
152
+ await self.connection._fetch(f'DROP TABLE {'IF EXISTS ' if not_exists_ok else ''}{self.name};')
153
+
154
+
155
+ class SQLiteConnection(ConnectionProtocol):
156
+ FIELDS_MAP: Final[dict] = {
157
+ BoolField: lambda field: 'INTEGER',
158
+ IntegerField: lambda field: 'INTEGER',
159
+ DecimalField: lambda field: 'DECIMAL',
160
+ FloatField: lambda field: 'REAL',
161
+ TextField: lambda field: 'TEXT',
162
+ TimeField: lambda field: 'INTEGER' + (
163
+ '' if field.default is None else (' DEFAULT ' + {
164
+ datetime: lambda dt: f'{dt.timestamp()}',
165
+ timedelta: lambda td: f'(unixepoch() + {td.total_seconds()})'
166
+ }[type(field.default)](field.default))
167
+ ),
168
+ TimeDeltaField: lambda field: 'INTEGER',
169
+ BinaryField: lambda field: 'BLOB',
170
+ UIDField: lambda field: 'INTEGER',
171
+ UUIDField: lambda field: 'TEXT'
172
+ }
173
+
174
+ def __init__(self, pool: SQLitePool, connection: aiosqlite.Connection, autocommit: bool = True):
175
+ self._pool = pool
176
+ self._connection = connection
177
+ self._autocommit = autocommit
178
+ self._in_transaction = False
179
+ self.collection = self.__getattr__
180
+
181
+ def __getattr__(self, key) -> CollectionProtocol:
182
+ return SQLiteCollection(self, key)
183
+
184
+ async def rollback(self) -> None:
185
+ await self._connection.rollback()
186
+ self._in_transaction = False
187
+
188
+ async def commit(self) -> None:
189
+ await self._connection.commit()
190
+ self._in_transaction = False
191
+
192
+ async def collections(self) -> list[str]:
193
+ rows = await self._fetch(
194
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
195
+ )
196
+ return [row[0] for row in rows]
197
+
198
+ async def create(self, model: Model, *, exists_ok=False) -> None:
199
+ model._update_meta(model)
200
+ fields = []
201
+ for field_name in model.__dict__.keys():
202
+ field = getattr(model, field_name)
203
+ if field_name.startswith('_') or not isinstance(field, Field):
204
+ continue
205
+ fields.append(
206
+ f'''
207
+ {field_name} {self.FIELDS_MAP[type(field)](field)}
208
+ {'PRIMARY KEY' if len(model._meta_primary_keys) == 1 and field_name in model._meta_primary_keys else ''}
209
+ {'AUTOINCREMENT' if hasattr(field, 'identity') and field.identity else ''}
210
+ {'UNIQUE' if hasattr(field, 'unique') and field.unique else ''}
211
+ {'NOT NULL' if (hasattr(field, 'not_null') and field.not_null) or field_name not in model._meta_primary_keys else ''}
212
+ {f'REFERENCES {field.reference.collection_name}({field.reference.field_name})'
213
+ if hasattr(field, 'reference') and field.reference else ''}
214
+ '''.strip()
215
+ )
216
+ await self._fetch(f'''
217
+ CREATE TABLE {'IF NOT EXISTS ' if exists_ok else ''}{model.__collection_name__()}(
218
+ {','.join(fields)}
219
+ {f',PRIMARY KEY ({','.join(model._meta_primary_keys)})' if len(model._meta_primary_keys) > 1 else ''}
220
+ );
221
+ '''.strip())
222
+
223
+ async def create_index(
224
+ self,
225
+ index_name: str,
226
+ collection_name: str,
227
+ fields: tuple[str],
228
+ *,
229
+ exists_ok: bool=False,
230
+ unique: bool=False
231
+ ) -> None:
232
+ print(f"""
233
+ CREATE {'UNIQUE ' if unique else ''}INDEX {'IF NOT EXISTS ' if exists_ok else ''}{index_name} ON {collection_name}({','.join(fields)});
234
+ """)
235
+ await self._fetch(f'''
236
+ CREATE {'UNIQUE ' if unique else ''}INDEX {'IF NOT EXISTS ' if exists_ok else ''}{index_name} ON {collection_name}({','.join(fields)});
237
+ ''')
238
+
239
+ async def __aenter__(self):
240
+ await self._connection.execute("BEGIN")
241
+ self._in_transaction = True
242
+ return self
243
+
244
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
245
+ try:
246
+ if not exc_type and self._autocommit:
247
+ await self.commit()
248
+ else:
249
+ await self.rollback()
250
+ finally:
251
+ await self._pool.release(self)
252
+
253
+ async def _fetch(self, query: str, *args, timeout: Optional[float] = None) -> list:
254
+ async with asyncio.timeout(timeout):
255
+ query = " ".join(line.strip() for line in query.split("\n")).strip()
256
+ async with self._connection.execute(query, args) as cursor:
257
+ return await cursor.fetchall()
258
+
259
+ async def _execute(self, query: str, *args) -> None:
260
+ query = " ".join(line.strip() for line in query.split("\n")).strip()
261
+ await self._connection.execute(query, args)
262
+
263
+
264
+ class SQLitePool(PoolProtocol):
265
+ def __init__(self, database: str, autocommit: bool = True, **kwargs):
266
+ self._database = database
267
+ self._autocommit = autocommit
268
+ self._kwargs = kwargs # additional aiosqlite.connect kwargs
269
+ self._pool: list[aiosqlite.Connection] = []
270
+ self._lock = asyncio.Lock()
271
+ self._tasks: list[Coroutine[Any, Any, Any]] = []
272
+ self._closed = False
273
+
274
+ async def acquire(self) -> SQLiteConnection:
275
+ async with self._lock:
276
+ if self._closed:
277
+ raise RuntimeError("Pool is closed")
278
+ if self._pool:
279
+ conn = self._pool.pop()
280
+ else:
281
+ conn = await aiosqlite.connect(self._database, **self._kwargs)
282
+ await conn.execute("PRAGMA foreign_keys = ON")
283
+ # row_factory будет установлен ниже единообразно
284
+ # Устанавливаем row_factory для любой сессии из пула или новой
285
+ conn.row_factory = aiosqlite.Row
286
+
287
+ sqlite_conn = SQLiteConnection(self, conn, self._autocommit)
288
+ tasks, self._tasks = self._tasks, []
289
+ for task in tasks:
290
+ await task(sqlite_conn)
291
+ return sqlite_conn
292
+
293
+ async def release(self, connection: SQLiteConnection) -> None:
294
+ conn = connection._connection
295
+ if connection._in_transaction:
296
+ await conn.rollback()
297
+ # Сбрасываем row_factory на стандартный (кортежи), чтобы не влиять на другие возможные использования
298
+ conn.row_factory = None
299
+ async with self._lock:
300
+ if not self._closed:
301
+ self._pool.append(conn)
302
+ else:
303
+ await conn.close()
304
+ del connection
305
+
306
+ def on_acquire(self, coroutine: Coroutine[ConnectionProtocol, Any, Any]) -> None:
307
+ self._tasks.append(coroutine)
308
+
309
+ async def close(self) -> None:
310
+ async with self._lock:
311
+ self._closed = True
312
+ for conn in self._pool:
313
+ await conn.close()
314
+ self._pool.clear()
315
+ await asyncio.sleep(0)
316
+
317
+
318
+ def SQLite(
319
+ dsn: Optional[str] = None,
320
+ *,
321
+ database: Optional[str] = None,
322
+ autocommit: bool = True,
323
+ **kwargs,
324
+ ) -> SQLitePool:
325
+ """
326
+ Create an asynchronous SQLite connection pool.
327
+
328
+ :param database: Path to the SQLite database file (use ":memory:" for in-memory)
329
+ :param autocommit: Whether to auto-commit after successful operations
330
+ :param kwargs: Additional arguments passed to aiosqlite.connect
331
+ :return: SQLitePool instance
332
+ """
333
+ if dsn:
334
+ return SQLitePool(dsn.split(':')[1], autocommit=autocommit, **kwargs)
335
+ return SQLitePool(database, autocommit=autocommit, **kwargs)
@@ -0,0 +1,9 @@
1
+ def typemap(
2
+ _dict: dict,
3
+ _types: dict | None) -> dict:
4
+ if not _types:
5
+ return _dict.copy()
6
+ _dict = _dict.copy()
7
+ for _key, _type in _types.items():
8
+ _dict[_key] = _type(_dict[_key])
9
+ return _dict
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "slinn-orm"
7
+ version = "0.0.3"
8
+ description = "ORM for Slinn"
9
+ requires-python = ">=3.14"
10
+ authors = [{ name = "Mark Radin", email = "openmiot@gmail.com" }]
11
+
12
+ [project.optional-dependencies]
13
+ postgres = ["asyncpg"]
14
+ sqlite = ["aiosqlite"]
15
+ full = ["slinn-orm[postgres]", "slinn-orm[sqlite]"]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["."]
19
+ include = ["orm*"]
20
+
21
+ [tool.uv]
22
+ managed = true
23
+ workspace = { }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: slinn-orm
3
+ Version: 0.0.3
4
+ Summary: ORM for Slinn
5
+ Author-email: Mark Radin <openmiot@gmail.com>
6
+ Requires-Python: >=3.14
7
+ License-File: LICENSE
8
+ Provides-Extra: postgres
9
+ Requires-Dist: asyncpg; extra == "postgres"
10
+ Provides-Extra: sqlite
11
+ Requires-Dist: aiosqlite; extra == "sqlite"
12
+ Provides-Extra: full
13
+ Requires-Dist: slinn-orm[postgres]; extra == "full"
14
+ Requires-Dist: slinn-orm[sqlite]; extra == "full"
15
+ Dynamic: license-file
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ pyproject.toml
3
+ orm/__init__.py
4
+ orm/attributed_dict.py
5
+ orm/models.py
6
+ orm/order.py
7
+ orm/typemap.py
8
+ orm/postgres/__init__.py
9
+ orm/sqlite/__init__.py
10
+ slinn_orm.egg-info/PKG-INFO
11
+ slinn_orm.egg-info/SOURCES.txt
12
+ slinn_orm.egg-info/dependency_links.txt
13
+ slinn_orm.egg-info/requires.txt
14
+ slinn_orm.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+
2
+ [full]
3
+ slinn-orm[postgres]
4
+ slinn-orm[sqlite]
5
+
6
+ [postgres]
7
+ asyncpg
8
+
9
+ [sqlite]
10
+ aiosqlite
@@ -0,0 +1 @@
1
+ orm