sqlite-persist 0.1.0__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,95 @@
1
+ Metadata-Version: 2.3
2
+ Name: sqlite-persist
3
+ Version: 0.1.0
4
+ Summary: Classe de base ORM légère pour SQLite
5
+ Requires-Dist: pytest ; extra == 'dev'
6
+ Requires-Dist: pytest-cov ; extra == 'dev'
7
+ Requires-Python: >=3.12
8
+ Provides-Extra: dev
9
+ Description-Content-Type: text/markdown
10
+
11
+ # sqlite-persistence
12
+
13
+ Classe de base ORM légère pour SQLite, sans dépendances externes.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ uv add git+https://github.com/vous/sqlite-persist.git
19
+ ```
20
+
21
+ ## Usage minimal
22
+
23
+ ```python
24
+ from sqlite_persist import Persistence
25
+ import sqlite3
26
+
27
+ con = sqlite3.connect("ma_base.db")
28
+
29
+ class User(Persistence):
30
+ def __init__(self, id, username, password):
31
+ self.id = id
32
+ self.username = username
33
+ self.password = password
34
+
35
+ # Lecture
36
+ user = User.get(1, con=con)
37
+ users = User.get_by({"username": "alice"}, con=con)
38
+ users = User.get_by({"username": "alice"}, order_by="username", con=con)
39
+
40
+ # Écriture
41
+ user.save(con=con) # insert ou replace selon présence de la PK
42
+ user.update(con=con) # update strict (la ligne doit exister)
43
+ user.insert(con=con) # insert, met à jour self.id après
44
+ user.delete(con=con)
45
+
46
+ # Batch
47
+ User.save_all([u1, u2, u3], con=con)
48
+ User.delete_all([u1, u2], con=con)
49
+ User.delete_where({"username": "bob"}, con=con)
50
+ User.update_where(values={"password": "..."}, where={"username": "alice"}, con=con)
51
+ ```
52
+
53
+ ## Injection automatique de connexion (ex. Flask)
54
+
55
+ Créer une classe intermédiaire qui surcharge `_con()` :
56
+
57
+ ```python
58
+ from sqlite_persistence import Persistence
59
+ from mon_app.db import get_db
60
+
61
+ class FlaskPersistence(Persistence):
62
+ @classmethod
63
+ def _con(cls):
64
+ return get_db()
65
+
66
+ class User(FlaskPersistence):
67
+ ...
68
+
69
+ # con= n'est plus nécessaire
70
+ user = User.get(1)
71
+ user.save()
72
+ ```
73
+
74
+ ## Clé primaire composite
75
+
76
+ ```python
77
+ class Day(FlaskPersistence):
78
+ _pk = ("month", "day")
79
+
80
+ day = Day.get("12", "01")
81
+ ```
82
+
83
+ ## Attributs transitoires
84
+
85
+ Tout attribut commençant par `_` est ignoré lors des opérations SQL :
86
+
87
+ ```python
88
+ class User(FlaskPersistence):
89
+ _table = "user" # si le nom de table diffère de la classe, par défaut le nom de la classe
90
+
91
+ def __init__(self, id, name):
92
+ self.id = id
93
+ self.name = name
94
+ self._transient = None # jamais envoyé en base
95
+ ```
@@ -0,0 +1,85 @@
1
+ # sqlite-persistence
2
+
3
+ Classe de base ORM légère pour SQLite, sans dépendances externes.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ uv add git+https://github.com/vous/sqlite-persist.git
9
+ ```
10
+
11
+ ## Usage minimal
12
+
13
+ ```python
14
+ from sqlite_persist import Persistence
15
+ import sqlite3
16
+
17
+ con = sqlite3.connect("ma_base.db")
18
+
19
+ class User(Persistence):
20
+ def __init__(self, id, username, password):
21
+ self.id = id
22
+ self.username = username
23
+ self.password = password
24
+
25
+ # Lecture
26
+ user = User.get(1, con=con)
27
+ users = User.get_by({"username": "alice"}, con=con)
28
+ users = User.get_by({"username": "alice"}, order_by="username", con=con)
29
+
30
+ # Écriture
31
+ user.save(con=con) # insert ou replace selon présence de la PK
32
+ user.update(con=con) # update strict (la ligne doit exister)
33
+ user.insert(con=con) # insert, met à jour self.id après
34
+ user.delete(con=con)
35
+
36
+ # Batch
37
+ User.save_all([u1, u2, u3], con=con)
38
+ User.delete_all([u1, u2], con=con)
39
+ User.delete_where({"username": "bob"}, con=con)
40
+ User.update_where(values={"password": "..."}, where={"username": "alice"}, con=con)
41
+ ```
42
+
43
+ ## Injection automatique de connexion (ex. Flask)
44
+
45
+ Créer une classe intermédiaire qui surcharge `_con()` :
46
+
47
+ ```python
48
+ from sqlite_persistence import Persistence
49
+ from mon_app.db import get_db
50
+
51
+ class FlaskPersistence(Persistence):
52
+ @classmethod
53
+ def _con(cls):
54
+ return get_db()
55
+
56
+ class User(FlaskPersistence):
57
+ ...
58
+
59
+ # con= n'est plus nécessaire
60
+ user = User.get(1)
61
+ user.save()
62
+ ```
63
+
64
+ ## Clé primaire composite
65
+
66
+ ```python
67
+ class Day(FlaskPersistence):
68
+ _pk = ("month", "day")
69
+
70
+ day = Day.get("12", "01")
71
+ ```
72
+
73
+ ## Attributs transitoires
74
+
75
+ Tout attribut commençant par `_` est ignoré lors des opérations SQL :
76
+
77
+ ```python
78
+ class User(FlaskPersistence):
79
+ _table = "user" # si le nom de table diffère de la classe, par défaut le nom de la classe
80
+
81
+ def __init__(self, id, name):
82
+ self.id = id
83
+ self.name = name
84
+ self._transient = None # jamais envoyé en base
85
+ ```
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "sqlite-persist"
3
+ version = "0.1.0"
4
+ description = "Classe de base ORM légère pour SQLite"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [project.optional-dependencies]
10
+ dev = ["pytest", "pytest-cov"]
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.10.4,<0.11.0"]
14
+ build-backend = "uv_build"
@@ -0,0 +1,3 @@
1
+ from sqlite_persist.persistence import Persistence
2
+
3
+ __all__ = ["Persistence"]
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from collections.abc import Collection
5
+ from typing import Any, ClassVar, Self
6
+
7
+
8
+ class Persistence:
9
+ _columns_cache: ClassVar[dict[str, set[str]]] = {}
10
+ _pk: str | tuple[str, ...] = "id"
11
+ _table: ClassVar[str | None] = None
12
+
13
+ # ── Utilitaires internes ───────────────────────────────────────────────
14
+
15
+ @classmethod
16
+ def _pk_columns(cls) -> tuple[str, ...]:
17
+ return (cls._pk,) if isinstance(cls._pk, str) else tuple(cls._pk)
18
+
19
+ def _pk_dict(self) -> dict:
20
+ return {col: self.__dict__[col] for col in self._pk_columns()}
21
+
22
+ @classmethod
23
+ def _pk_where_clause(cls) -> str:
24
+ return " AND ".join(f"{col}=:{col}" for col in cls._pk_columns())
25
+
26
+ @classmethod
27
+ def _table_name(cls) -> str:
28
+ return cls._table or cls.__name__
29
+
30
+ @classmethod
31
+ def _con(cls) -> sqlite3.Connection:
32
+ raise NotImplementedError("Passez con= explicitement ou surchargez _con().")
33
+
34
+ @classmethod
35
+ def _check_columns(cls, con: sqlite3.Connection, keys: Collection) -> None:
36
+ if cls._table_name() not in cls._columns_cache:
37
+ cur = con.execute(f"SELECT name FROM pragma_table_info('{cls._table_name()}')") # noqa: S608
38
+ cls._columns_cache[cls._table_name()] = {row[0] for row in cur.fetchall()}
39
+
40
+ valid_columns = cls._columns_cache[cls._table_name()]
41
+ invalid = set(keys) - valid_columns
42
+ if invalid:
43
+ raise ValueError(f"Colonnes invalides : {invalid}")
44
+
45
+ def _row_data(self) -> dict:
46
+ return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
47
+
48
+ def __repr__(self) -> str:
49
+ pk_cols = self._pk_columns()
50
+ pk_str = ", ".join(f"{col}={self.__dict__.get(col)!r}" for col in pk_cols)
51
+ rest_str = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items() if k not in pk_cols and not k.startswith("_"))
52
+ return f"{self._table_name()}[{pk_str} | {rest_str}]"
53
+
54
+ # ── Lecture ────────────────────────────────────────────────────────────
55
+
56
+ @classmethod
57
+ def get(cls, *pk_values, con: sqlite3.Connection | None = None) -> Self | None:
58
+ con: sqlite3.Connection = con or cls._con()
59
+ pk_cols = cls._pk_columns()
60
+ if len(pk_values) != len(pk_cols):
61
+ raise ValueError(f"{cls._table_name()} attend {len(pk_cols)} valeur(s) de PK : {pk_cols}")
62
+ params = dict(zip(pk_cols, pk_values, strict=False))
63
+
64
+ cursor = con.cursor()
65
+ cursor.row_factory = sqlite3.Row
66
+ row = cursor.execute(
67
+ f"SELECT * FROM {cls._table_name()} WHERE {cls._pk_where_clause()}", # noqa: S608
68
+ params,
69
+ ).fetchone()
70
+ cursor.close()
71
+
72
+ if row is not None:
73
+ o: Self = object.__new__(cls)
74
+ o.__dict__.update(dict(row))
75
+ return o
76
+ return None
77
+
78
+ @classmethod
79
+ def get_by(
80
+ cls,
81
+ parameters: dict[str, Any],
82
+ order_by: str | list[str] | None = None,
83
+ con: sqlite3.Connection | None = None,
84
+ ) -> list[Self]:
85
+ con: sqlite3.Connection = con or cls._con()
86
+ cls._check_columns(con, parameters.keys())
87
+ where = " AND ".join(f"{k}=:{k}" for k in parameters)
88
+ query = f"SELECT * FROM {cls._table_name()} WHERE {where}" # noqa: S608
89
+
90
+ if order_by is not None:
91
+ cols = [order_by] if isinstance(order_by, str) else order_by
92
+ query += f" ORDER BY {', '.join(cols)}"
93
+
94
+ cursor = con.cursor()
95
+ cursor.row_factory = sqlite3.Row
96
+ rows = cursor.execute(query, parameters).fetchall()
97
+ cursor.close()
98
+
99
+ objects: list[Self] = []
100
+ for row in rows:
101
+ o: Self = object.__new__(cls)
102
+ o.__dict__.update(dict(row))
103
+ objects.append(o)
104
+ return objects
105
+
106
+ # ── Insertion ──────────────────────────────────────────────────────────
107
+
108
+ def insert(self, con: sqlite3.Connection | None = None) -> None:
109
+ con: sqlite3.Connection = con or self._con()
110
+ data: dict = self._row_data()
111
+ self._check_columns(con, data.keys())
112
+ cols = ", ".join(data.keys())
113
+ placeholders = ", ".join(f":{k}" for k in data)
114
+ cursor = con.execute(
115
+ f"INSERT INTO {self._table_name()} ({cols}) VALUES ({placeholders})", # noqa: S608
116
+ data,
117
+ )
118
+ con.commit()
119
+ if cursor.lastrowid is not None:
120
+ pk_cols = self._pk_columns()
121
+ self.__dict__[pk_cols[0]] = cursor.lastrowid
122
+
123
+ @classmethod
124
+ def insert_all(cls, items: list[Self], con: sqlite3.Connection | None = None) -> None:
125
+ con: sqlite3.Connection = con or cls._con()
126
+ if not items:
127
+ return
128
+ sample = items[0]._row_data()
129
+ cls._check_columns(con, sample.keys())
130
+ cols = ", ".join(sample.keys())
131
+ placeholders = ", ".join(f":{k}" for k in sample)
132
+ cursor = con.executemany(
133
+ f"INSERT INTO {cls._table_name()} ({cols}) VALUES ({placeholders})", # noqa: S608
134
+ [item._row_data() for item in items],
135
+ )
136
+ con.commit()
137
+ if cursor.lastrowid is not None:
138
+ first_id = cursor.lastrowid - len(items) + 1
139
+ pk_col = cls._pk_columns()[0]
140
+ for item, generated_id in zip(items, range(first_id, cursor.lastrowid + 1), strict=False):
141
+ item.__dict__[pk_col] = generated_id
142
+
143
+ # ── Mise à jour ────────────────────────────────────────────────────────
144
+
145
+ def update(self, con: sqlite3.Connection | None = None) -> None:
146
+ con: sqlite3.Connection = con or self._con()
147
+ data: dict = self._row_data()
148
+ self._check_columns(con, data.keys())
149
+ pk_cols = self._pk_columns()
150
+ missing = [col for col in pk_cols if col not in data]
151
+ if missing:
152
+ raise ValueError(f"Colonnes PK manquantes dans l'instance : {missing}")
153
+ set_clause = ", ".join(f"{k}=:{k}" for k in data if k not in pk_cols)
154
+ con.execute(
155
+ f"UPDATE {self._table_name()} SET {set_clause} WHERE {self._pk_where_clause()}", # noqa: S608
156
+ data,
157
+ )
158
+ con.commit()
159
+
160
+ @classmethod
161
+ def update_where(cls, values: dict[str, Any], where: dict[str, Any], con: sqlite3.Connection | None = None) -> None:
162
+ con: sqlite3.Connection = con or cls._con()
163
+ cls._check_columns(con, list(values.keys()) + list(where.keys()))
164
+ set_clause = ", ".join(f"{k}=:set_{k}" for k in values)
165
+ where_clause = " AND ".join(f"{k}=:where_{k}" for k in where)
166
+ params = {f"set_{k}": v for k, v in values.items()} | {f"where_{k}": v for k, v in where.items()}
167
+ con.execute(
168
+ f"UPDATE {cls._table_name()} SET {set_clause} WHERE {where_clause}", # noqa: S608
169
+ params,
170
+ )
171
+ con.commit()
172
+
173
+ @classmethod
174
+ def update_all(cls, items: list[Self], con: sqlite3.Connection | None = None) -> None:
175
+ con: sqlite3.Connection = con or cls._con()
176
+ if not items:
177
+ return
178
+ pk_cols = cls._pk_columns()
179
+ sample = items[0]._row_data()
180
+ cls._check_columns(con, sample.keys())
181
+ missing = [col for col in pk_cols if col not in sample]
182
+ if missing:
183
+ raise ValueError(f"Colonnes PK manquantes : {missing}")
184
+ set_clause = ", ".join(f"{k}=:{k}" for k in sample if k not in pk_cols)
185
+ con.executemany(
186
+ f"UPDATE {cls._table_name()} SET {set_clause} WHERE {cls._pk_where_clause()}", # noqa: S608
187
+ [item._row_data() for item in items],
188
+ )
189
+ con.commit()
190
+
191
+ # ── Suppression ────────────────────────────────────────────────────────
192
+
193
+ def delete(self, con: sqlite3.Connection | None = None) -> None:
194
+ con: sqlite3.Connection = con or self._con()
195
+ con.execute(
196
+ f"DELETE FROM {self._table_name()} WHERE {self._pk_where_clause()}", # noqa: S608
197
+ self._pk_dict(),
198
+ )
199
+ con.commit()
200
+
201
+ @classmethod
202
+ def delete_where(cls, parameters: dict[str, Any], con: sqlite3.Connection | None = None) -> None:
203
+ con: sqlite3.Connection = con or cls._con()
204
+ cls._check_columns(con, parameters.keys())
205
+ where_clause = " AND ".join(f"{k}=:{k}" for k in parameters)
206
+ con.execute(
207
+ f"DELETE FROM {cls._table_name()} WHERE {where_clause}", # noqa: S608
208
+ parameters,
209
+ )
210
+ con.commit()
211
+
212
+ @classmethod
213
+ def delete_all(cls, items: list[Self], con: sqlite3.Connection | None = None) -> None:
214
+ con: sqlite3.Connection = con or cls._con()
215
+ if not items:
216
+ return
217
+ con.executemany(
218
+ f"DELETE FROM {cls._table_name()} WHERE {cls._pk_where_clause()}", # noqa: S608
219
+ [item._pk_dict() for item in items],
220
+ )
221
+ con.commit()
222
+
223
+ # ── Upsert ────────────────────────────────────────────────────────
224
+
225
+ def upsert(self, con: sqlite3.Connection | None = None) -> None:
226
+ con: sqlite3.Connection = con or self._con()
227
+ data: dict = self._row_data()
228
+ self._check_columns(con, data.keys())
229
+ cols = ", ".join(data.keys())
230
+ placeholders = ", ".join(f":{k}" for k in data)
231
+ cursor = con.execute(
232
+ f"INSERT OR REPLACE INTO {self._table_name()} ({cols}) VALUES ({placeholders})", # noqa: S608
233
+ data,
234
+ )
235
+ con.commit()
236
+ # Récupère l'id généré uniquement si la PK était absente
237
+ pk_cols = self._pk_columns()
238
+ if len(pk_cols) == 1 and self.__dict__.get(pk_cols[0]) is None:
239
+ self.__dict__[pk_cols[0]] = cursor.lastrowid
240
+
241
+ @classmethod
242
+ def upsert_all(cls, items: list[Self], con: sqlite3.Connection | None = None) -> None:
243
+ if not items:
244
+ return
245
+ con: sqlite3.Connection = con or cls._con()
246
+ sample = items[0]._row_data()
247
+ cls._check_columns(con, sample.keys())
248
+ cols = ", ".join(sample.keys())
249
+ placeholders = ", ".join(f":{k}" for k in sample)
250
+ con.executemany(
251
+ f"INSERT OR REPLACE INTO {cls._table_name()} ({cols}) VALUES ({placeholders})", # noqa: S608
252
+ [item._row_data() for item in items],
253
+ )
254
+ con.commit()
File without changes