pydoptic-sql 0.0.1.post1.dev2__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.
- pydoptic_sql/__init__.py +49 -0
- pydoptic_sql/py.typed +0 -0
- pydoptic_sql/sql_computed.py +93 -0
- pydoptic_sql/sql_constraint.py +782 -0
- pydoptic_sql/sql_having.py +728 -0
- pydoptic_sql/sql_order.py +28 -0
- pydoptic_sql/sql_query.py +1144 -0
- pydoptic_sql/sql_service.py +335 -0
- pydoptic_sql/sql_table.py +188 -0
- pydoptic_sql-0.0.1.post1.dev2.dist-info/METADATA +75 -0
- pydoptic_sql-0.0.1.post1.dev2.dist-info/RECORD +14 -0
- pydoptic_sql-0.0.1.post1.dev2.dist-info/WHEEL +5 -0
- pydoptic_sql-0.0.1.post1.dev2.dist-info/licenses/LICENSE +21 -0
- pydoptic_sql-0.0.1.post1.dev2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Generic, Iterator, List, Sequence, Tuple, Type, cast
|
|
6
|
+
|
|
7
|
+
from pydoptic.base_model import PartialModel
|
|
8
|
+
from pydoptic.selector import PropSelect
|
|
9
|
+
from pydoptic_sql import SqlQuery
|
|
10
|
+
from pydoptic_sql.sql_computed import Computed, ComputedResult
|
|
11
|
+
from pydoptic_sql.sql_query import TC, TC1, TC2, TC3, R, Query1, Query2, Query3, Query4, ComputedQuery1, ComputedQuery2, ComputedQuery3, ComputedQuery4
|
|
12
|
+
|
|
13
|
+
from psycopg import Connection, Cursor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqlClient:
|
|
17
|
+
@contextmanager
|
|
18
|
+
def open(self) -> Iterator['SqlTransaction']:
|
|
19
|
+
raise NotImplementedError()
|
|
20
|
+
|
|
21
|
+
class SqlResponse(Generic[R]):
|
|
22
|
+
def fetchone(self) -> R | None:
|
|
23
|
+
raise NotImplementedError()
|
|
24
|
+
|
|
25
|
+
def stream(self) -> Iterator[R]:
|
|
26
|
+
raise NotImplementedError()
|
|
27
|
+
|
|
28
|
+
class SqlTransaction:
|
|
29
|
+
def commit(self) -> None:
|
|
30
|
+
raise NotImplementedError()
|
|
31
|
+
|
|
32
|
+
def rollback(self) -> None:
|
|
33
|
+
raise NotImplementedError()
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
raise NotImplementedError()
|
|
37
|
+
|
|
38
|
+
def execute(self, query: SqlQuery[R]) -> SqlResponse[R]:
|
|
39
|
+
raise NotImplementedError()
|
|
40
|
+
|
|
41
|
+
class EmptyPgSqlResponse(SqlResponse[R]):
|
|
42
|
+
def fetchone(self) -> R | None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def stream(self) -> Iterator[R]:
|
|
46
|
+
seq: Sequence[R] = []
|
|
47
|
+
return iter(seq)
|
|
48
|
+
|
|
49
|
+
def _computed_record(computed: Sequence[Computed[Any, Any]], sql_record: Tuple[Any, ...], offset: int) -> ComputedResult:
|
|
50
|
+
"""Build a ComputedResult from the trailing `len(computed)` columns of a row, keyed by each
|
|
51
|
+
Computed's alias -- the computed columns always come after the plain ones, in the same order
|
|
52
|
+
the query builder put them in (see _computed_sql_parts in sql_query.py)."""
|
|
53
|
+
return ComputedResult(**{c.label: sql_record[offset + i] for i, c in enumerate(computed)})
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class PsycoPgSqlResponse(Generic[TC], SqlResponse[PartialModel[TC]]):
|
|
57
|
+
model: Type[TC]
|
|
58
|
+
cursor: Cursor
|
|
59
|
+
selection: Sequence[PropSelect[TC, Any]]
|
|
60
|
+
|
|
61
|
+
def __make_record(self, sql_record: Tuple[Any,...]) -> PartialModel[TC]:
|
|
62
|
+
data: Dict[str, Any] = {}
|
|
63
|
+
for i, prop in enumerate(self.selection):
|
|
64
|
+
data[prop.label] = sql_record[i]
|
|
65
|
+
return PartialModel(self.model, **data)
|
|
66
|
+
|
|
67
|
+
def fetchone(self) -> PartialModel[TC] | None:
|
|
68
|
+
result = self.cursor.fetchone()
|
|
69
|
+
if result is not None:
|
|
70
|
+
return self.__make_record(result)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def stream(self) -> Iterator[PartialModel[TC]]:
|
|
74
|
+
for row in self.cursor:
|
|
75
|
+
yield self.__make_record(row)
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PsycoPgComputedResponse1(Generic[TC], SqlResponse[Tuple[PartialModel[TC], ComputedResult]]):
|
|
79
|
+
model: Type[TC]
|
|
80
|
+
cursor: Cursor
|
|
81
|
+
selection: Sequence[PropSelect[TC, Any]]
|
|
82
|
+
computed: Sequence[Computed[TC, Any]]
|
|
83
|
+
|
|
84
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], ComputedResult]:
|
|
85
|
+
data: Dict[str, Any] = {}
|
|
86
|
+
for i, prop in enumerate(self.selection):
|
|
87
|
+
data[prop.label] = sql_record[i]
|
|
88
|
+
return PartialModel(self.model, **data), _computed_record(self.computed, sql_record, len(self.selection))
|
|
89
|
+
|
|
90
|
+
def fetchone(self) -> Tuple[PartialModel[TC], ComputedResult] | None:
|
|
91
|
+
result = self.cursor.fetchone()
|
|
92
|
+
if result is not None:
|
|
93
|
+
return self.__make_record(result)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], ComputedResult]]:
|
|
97
|
+
for row in self.cursor:
|
|
98
|
+
yield self.__make_record(row)
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class PsycoPgJoinResponse2(Generic[TC, TC1], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1]]]):
|
|
102
|
+
table1: Type[TC]
|
|
103
|
+
table2: Type[TC1]
|
|
104
|
+
cursor: Cursor
|
|
105
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any]]
|
|
106
|
+
|
|
107
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1]]:
|
|
108
|
+
data1: Dict[str, Any] = {}
|
|
109
|
+
data2: Dict[str, Any] = {}
|
|
110
|
+
for i, prop in enumerate(self.selection):
|
|
111
|
+
if prop.origin is self.table1:
|
|
112
|
+
data1[prop.label] = sql_record[i]
|
|
113
|
+
else:
|
|
114
|
+
data2[prop.label] = sql_record[i]
|
|
115
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2)
|
|
116
|
+
|
|
117
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1]] | None:
|
|
118
|
+
result = self.cursor.fetchone()
|
|
119
|
+
if result is not None:
|
|
120
|
+
return self.__make_record(result)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1]]]:
|
|
124
|
+
for row in self.cursor:
|
|
125
|
+
yield self.__make_record(row)
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class PsycoPgComputedResponse2(Generic[TC, TC1], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1], ComputedResult]]):
|
|
129
|
+
table1: Type[TC]
|
|
130
|
+
table2: Type[TC1]
|
|
131
|
+
cursor: Cursor
|
|
132
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any]]
|
|
133
|
+
computed: Sequence[Computed[TC, Any] | Computed[TC1, Any]]
|
|
134
|
+
|
|
135
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1], ComputedResult]:
|
|
136
|
+
data1: Dict[str, Any] = {}
|
|
137
|
+
data2: Dict[str, Any] = {}
|
|
138
|
+
for i, prop in enumerate(self.selection):
|
|
139
|
+
if prop.origin is self.table1:
|
|
140
|
+
data1[prop.label] = sql_record[i]
|
|
141
|
+
else:
|
|
142
|
+
data2[prop.label] = sql_record[i]
|
|
143
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2), _computed_record(self.computed, sql_record, len(self.selection))
|
|
144
|
+
|
|
145
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1], ComputedResult] | None:
|
|
146
|
+
result = self.cursor.fetchone()
|
|
147
|
+
if result is not None:
|
|
148
|
+
return self.__make_record(result)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1], ComputedResult]]:
|
|
152
|
+
for row in self.cursor:
|
|
153
|
+
yield self.__make_record(row)
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class PsycoPgJoinResponse3(Generic[TC, TC1, TC2], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2]]]):
|
|
157
|
+
table1: Type[TC]
|
|
158
|
+
table2: Type[TC1]
|
|
159
|
+
table3: Type[TC2]
|
|
160
|
+
cursor: Cursor
|
|
161
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any] | PropSelect[TC2, Any]]
|
|
162
|
+
|
|
163
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2]]:
|
|
164
|
+
data1: Dict[str, Any] = {}
|
|
165
|
+
data2: Dict[str, Any] = {}
|
|
166
|
+
data3: Dict[str, Any] = {}
|
|
167
|
+
for i, prop in enumerate(self.selection):
|
|
168
|
+
if prop.origin is self.table1:
|
|
169
|
+
data1[prop.label] = sql_record[i]
|
|
170
|
+
elif prop.origin is self.table2:
|
|
171
|
+
data2[prop.label] = sql_record[i]
|
|
172
|
+
else:
|
|
173
|
+
data3[prop.label] = sql_record[i]
|
|
174
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2), PartialModel(self.table3, **data3)
|
|
175
|
+
|
|
176
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2]] | None:
|
|
177
|
+
result = self.cursor.fetchone()
|
|
178
|
+
if result is not None:
|
|
179
|
+
return self.__make_record(result)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2]]]:
|
|
183
|
+
for row in self.cursor:
|
|
184
|
+
yield self.__make_record(row)
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class PsycoPgComputedResponse3(Generic[TC, TC1, TC2], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], ComputedResult]]):
|
|
188
|
+
table1: Type[TC]
|
|
189
|
+
table2: Type[TC1]
|
|
190
|
+
table3: Type[TC2]
|
|
191
|
+
cursor: Cursor
|
|
192
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any] | PropSelect[TC2, Any]]
|
|
193
|
+
computed: Sequence[Computed[TC, Any] | Computed[TC1, Any] | Computed[TC2, Any]]
|
|
194
|
+
|
|
195
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], ComputedResult]:
|
|
196
|
+
data1: Dict[str, Any] = {}
|
|
197
|
+
data2: Dict[str, Any] = {}
|
|
198
|
+
data3: Dict[str, Any] = {}
|
|
199
|
+
for i, prop in enumerate(self.selection):
|
|
200
|
+
if prop.origin is self.table1:
|
|
201
|
+
data1[prop.label] = sql_record[i]
|
|
202
|
+
elif prop.origin is self.table2:
|
|
203
|
+
data2[prop.label] = sql_record[i]
|
|
204
|
+
else:
|
|
205
|
+
data3[prop.label] = sql_record[i]
|
|
206
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2), PartialModel(self.table3, **data3), _computed_record(self.computed, sql_record, len(self.selection))
|
|
207
|
+
|
|
208
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], ComputedResult] | None:
|
|
209
|
+
result = self.cursor.fetchone()
|
|
210
|
+
if result is not None:
|
|
211
|
+
return self.__make_record(result)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], ComputedResult]]:
|
|
215
|
+
for row in self.cursor:
|
|
216
|
+
yield self.__make_record(row)
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class PsycoPgJoinResponse4(Generic[TC, TC1, TC2, TC3], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3]]]):
|
|
220
|
+
table1: Type[TC]
|
|
221
|
+
table2: Type[TC1]
|
|
222
|
+
table3: Type[TC2]
|
|
223
|
+
table4: Type[TC3]
|
|
224
|
+
cursor: Cursor
|
|
225
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any] | PropSelect[TC2, Any] | PropSelect[TC3, Any]]
|
|
226
|
+
|
|
227
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3]]:
|
|
228
|
+
data1: Dict[str, Any] = {}
|
|
229
|
+
data2: Dict[str, Any] = {}
|
|
230
|
+
data3: Dict[str, Any] = {}
|
|
231
|
+
data4: Dict[str, Any] = {}
|
|
232
|
+
for i, prop in enumerate(self.selection):
|
|
233
|
+
if prop.origin is self.table1:
|
|
234
|
+
data1[prop.label] = sql_record[i]
|
|
235
|
+
elif prop.origin is self.table2:
|
|
236
|
+
data2[prop.label] = sql_record[i]
|
|
237
|
+
elif prop.origin is self.table3:
|
|
238
|
+
data3[prop.label] = sql_record[i]
|
|
239
|
+
else:
|
|
240
|
+
data4[prop.label] = sql_record[i]
|
|
241
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2), PartialModel(self.table3, **data3), PartialModel(self.table4, **data4)
|
|
242
|
+
|
|
243
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3]] | None:
|
|
244
|
+
result = self.cursor.fetchone()
|
|
245
|
+
if result is not None:
|
|
246
|
+
return self.__make_record(result)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3]]]:
|
|
250
|
+
for row in self.cursor:
|
|
251
|
+
yield self.__make_record(row)
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class PsycoPgComputedResponse4(Generic[TC, TC1, TC2, TC3], SqlResponse[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3], ComputedResult]]):
|
|
255
|
+
table1: Type[TC]
|
|
256
|
+
table2: Type[TC1]
|
|
257
|
+
table3: Type[TC2]
|
|
258
|
+
table4: Type[TC3]
|
|
259
|
+
cursor: Cursor
|
|
260
|
+
selection: Sequence[PropSelect[TC, Any] | PropSelect[TC1, Any] | PropSelect[TC2, Any] | PropSelect[TC3, Any]]
|
|
261
|
+
computed: Sequence[Computed[TC, Any] | Computed[TC1, Any] | Computed[TC2, Any] | Computed[TC3, Any]]
|
|
262
|
+
|
|
263
|
+
def __make_record(self, sql_record: Tuple[Any, ...]) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3], ComputedResult]:
|
|
264
|
+
data1: Dict[str, Any] = {}
|
|
265
|
+
data2: Dict[str, Any] = {}
|
|
266
|
+
data3: Dict[str, Any] = {}
|
|
267
|
+
data4: Dict[str, Any] = {}
|
|
268
|
+
for i, prop in enumerate(self.selection):
|
|
269
|
+
if prop.origin is self.table1:
|
|
270
|
+
data1[prop.label] = sql_record[i]
|
|
271
|
+
elif prop.origin is self.table2:
|
|
272
|
+
data2[prop.label] = sql_record[i]
|
|
273
|
+
elif prop.origin is self.table3:
|
|
274
|
+
data3[prop.label] = sql_record[i]
|
|
275
|
+
else:
|
|
276
|
+
data4[prop.label] = sql_record[i]
|
|
277
|
+
return PartialModel(self.table1, **data1), PartialModel(self.table2, **data2), PartialModel(self.table3, **data3), PartialModel(self.table4, **data4), _computed_record(self.computed, sql_record, len(self.selection))
|
|
278
|
+
|
|
279
|
+
def fetchone(self) -> Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3], ComputedResult] | None:
|
|
280
|
+
result = self.cursor.fetchone()
|
|
281
|
+
if result is not None:
|
|
282
|
+
return self.__make_record(result)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def stream(self) -> Iterator[Tuple[PartialModel[TC], PartialModel[TC1], PartialModel[TC2], PartialModel[TC3], ComputedResult]]:
|
|
286
|
+
for row in self.cursor:
|
|
287
|
+
yield self.__make_record(row)
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class PsycoPgSqlTransaction(SqlTransaction):
|
|
291
|
+
connection: Connection
|
|
292
|
+
cursor: Cursor
|
|
293
|
+
|
|
294
|
+
def commit(self) -> None:
|
|
295
|
+
self.connection.commit()
|
|
296
|
+
|
|
297
|
+
def rollback(self) -> None:
|
|
298
|
+
self.connection.rollback()
|
|
299
|
+
|
|
300
|
+
def close(self) -> None:
|
|
301
|
+
self.cursor.close()
|
|
302
|
+
|
|
303
|
+
def execute(self, query: SqlQuery[R]) -> SqlResponse[R]:
|
|
304
|
+
sql, params = query.to_sql_params()
|
|
305
|
+
self.cursor.execute(sql, params)
|
|
306
|
+
match query:
|
|
307
|
+
case ComputedQuery1():
|
|
308
|
+
return cast(SqlResponse[R], PsycoPgComputedResponse1(query.table1, self.cursor, query._resolved_selection(), query._computed))
|
|
309
|
+
case ComputedQuery2():
|
|
310
|
+
return cast(SqlResponse[R], PsycoPgComputedResponse2(query.table1, query.table2, self.cursor, query._resolved_selection(), query._computed))
|
|
311
|
+
case ComputedQuery3():
|
|
312
|
+
return cast(SqlResponse[R], PsycoPgComputedResponse3(query.table1, query.table2, query.table3, self.cursor, query._resolved_selection(), query._computed))
|
|
313
|
+
case ComputedQuery4():
|
|
314
|
+
return cast(SqlResponse[R], PsycoPgComputedResponse4(query.table1, query.table2, query.table3, query.table4, self.cursor, query._resolved_selection(), query._computed))
|
|
315
|
+
case Query1():
|
|
316
|
+
# Query1[TC]'s R is always PartialModel[TC], which is exactly the caller's R here,
|
|
317
|
+
# but match narrowing can't invert R back to TC to prove that statically.
|
|
318
|
+
return cast(SqlResponse[R], PsycoPgSqlResponse(query.table1, self.cursor, query._resolved_selection()))
|
|
319
|
+
case Query2():
|
|
320
|
+
return cast(SqlResponse[R], PsycoPgJoinResponse2(query.table1, query.table2, self.cursor, query._resolved_selection()))
|
|
321
|
+
case Query3():
|
|
322
|
+
return cast(SqlResponse[R], PsycoPgJoinResponse3(query.table1, query.table2, query.table3, self.cursor, query._resolved_selection()))
|
|
323
|
+
case Query4():
|
|
324
|
+
return cast(SqlResponse[R], PsycoPgJoinResponse4(query.table1, query.table2, query.table3, query.table4, self.cursor, query._resolved_selection()))
|
|
325
|
+
case _:
|
|
326
|
+
return EmptyPgSqlResponse()
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class PsycoPgSqlClient(SqlClient):
|
|
330
|
+
connection: Connection
|
|
331
|
+
|
|
332
|
+
@contextmanager
|
|
333
|
+
def open(self) -> Iterator['SqlTransaction']:
|
|
334
|
+
with self.connection.cursor() as cursor:
|
|
335
|
+
yield PsycoPgSqlTransaction(self.connection, cursor)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import date, datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, ClassVar, Generic, List, NotRequired, Type, TypeVar, TypedDict, Unpack
|
|
5
|
+
from pydoptic import BaseModel, select
|
|
6
|
+
from pydoptic.selector import PropSelect
|
|
7
|
+
|
|
8
|
+
A = TypeVar('A')
|
|
9
|
+
|
|
10
|
+
class SqlTable(BaseModel):
|
|
11
|
+
table_name: ClassVar[str]
|
|
12
|
+
|
|
13
|
+
T = TypeVar('T', bound=SqlTable)
|
|
14
|
+
|
|
15
|
+
class ColumnType:
|
|
16
|
+
def to_sql(self) -> str:
|
|
17
|
+
raise NotImplementedError()
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_type(cls, type: Type[Any]):
|
|
21
|
+
if type is int:
|
|
22
|
+
return cls.INT()
|
|
23
|
+
if type is str:
|
|
24
|
+
return cls.TEXT()
|
|
25
|
+
if type is bool:
|
|
26
|
+
return cls.BOOL()
|
|
27
|
+
if type is float:
|
|
28
|
+
return cls.REAL()
|
|
29
|
+
if type is date:
|
|
30
|
+
return cls.DATE()
|
|
31
|
+
if type is datetime:
|
|
32
|
+
return cls.DATE()
|
|
33
|
+
raise ValueError(f'Unknown column type: {type}')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def INT(cls) -> 'Int':
|
|
38
|
+
return Int.Int
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def SMALLINT(cls) -> 'Int':
|
|
42
|
+
return Int.SmallInt
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def BIGINT(cls) -> 'Int':
|
|
46
|
+
return Int.BigInt
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def REAL(cls) -> 'Float':
|
|
50
|
+
return Float.Real
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def DOUBLE(cls) -> 'Float':
|
|
54
|
+
return Float.Double
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def BOOL(cls) -> 'OtherType':
|
|
58
|
+
return OtherType.Bool
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def BLOB(cls) -> 'OtherType':
|
|
62
|
+
return OtherType.Blob
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def UUID(cls) -> 'OtherType':
|
|
66
|
+
return OtherType.UUID
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def JSON(cls) -> 'OtherType':
|
|
70
|
+
return OtherType.Json
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def DATE(cls) -> 'OtherType':
|
|
74
|
+
return OtherType.Date
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def TEXT(cls, unicode: bool = False) -> 'Text':
|
|
78
|
+
return Text.NText if unicode else Text.Text
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def CHAR(cls, size: int, unicode: bool = False) -> 'Char':
|
|
82
|
+
return Char(size, unicode)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def VARCHAR(cls, size: int, unicode: bool = False) -> 'VarChar':
|
|
86
|
+
return VarChar(size, unicode)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Char(ColumnType):
|
|
91
|
+
size: int
|
|
92
|
+
unicode: bool = False
|
|
93
|
+
|
|
94
|
+
def to_sql(self) -> str:
|
|
95
|
+
if self.unicode:
|
|
96
|
+
return f'NCHAR({self.size})'
|
|
97
|
+
return f'CHAR({self.size})'
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class VarChar(ColumnType):
|
|
101
|
+
size: int
|
|
102
|
+
unicode: bool = False
|
|
103
|
+
|
|
104
|
+
def to_sql(self) -> str:
|
|
105
|
+
if self.unicode:
|
|
106
|
+
return f'NVARCHAR({self.size})'
|
|
107
|
+
return f'VARCHAR({self.size})'
|
|
108
|
+
|
|
109
|
+
class Int(ColumnType, Enum):
|
|
110
|
+
Int = 'INTEGER'
|
|
111
|
+
SmallInt = 'SMALLINT'
|
|
112
|
+
BigInt = 'BIGINT'
|
|
113
|
+
|
|
114
|
+
def to_sql(self) -> str:
|
|
115
|
+
return self.value
|
|
116
|
+
|
|
117
|
+
class Float(ColumnType, Enum):
|
|
118
|
+
Real = 'REAL'
|
|
119
|
+
Double = 'DOUBLE PRECISION'
|
|
120
|
+
|
|
121
|
+
def to_sql(self) -> str:
|
|
122
|
+
return self.value
|
|
123
|
+
|
|
124
|
+
class Text(ColumnType, Enum):
|
|
125
|
+
Text = 'TEXT'
|
|
126
|
+
NText = 'NTEXT'
|
|
127
|
+
|
|
128
|
+
def to_sql(self) -> str:
|
|
129
|
+
return self.value
|
|
130
|
+
|
|
131
|
+
class OtherType(ColumnType, Enum):
|
|
132
|
+
Bool = 'BOOLEAN'
|
|
133
|
+
UUID = 'UUID' # type: ignore[assignment]
|
|
134
|
+
Json = 'JSON'
|
|
135
|
+
Blob = 'BLOB'
|
|
136
|
+
Date = 'DATE'
|
|
137
|
+
|
|
138
|
+
def to_sql(self) -> str:
|
|
139
|
+
return self.value
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class ManualColumnType(ColumnType):
|
|
143
|
+
type: str
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ColumnConstraint:
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class __PrimaryKey(ColumnConstraint):
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
PrimaryKey = __PrimaryKey()
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class __Unique(ColumnConstraint):
|
|
157
|
+
...
|
|
158
|
+
|
|
159
|
+
Unique = __Unique()
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class __AutoIncrement(ColumnConstraint):
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
AutoIncrement = __AutoIncrement()
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class ForeignKey(Generic[T, A], ColumnConstraint):
|
|
169
|
+
references: PropSelect[T, A]
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class Check(ColumnConstraint):
|
|
173
|
+
constraint: str
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class Default(ColumnConstraint):
|
|
177
|
+
value: Any
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class ManualColumnConstraint(ColumnConstraint):
|
|
181
|
+
type: str
|
|
182
|
+
|
|
183
|
+
class ColumnInfo(TypedDict):
|
|
184
|
+
type: NotRequired[ColumnType]
|
|
185
|
+
constraints: NotRequired[List[ColumnConstraint]]
|
|
186
|
+
|
|
187
|
+
def column(name: str | None = None, **column_info: Unpack[ColumnInfo]) -> Any:
|
|
188
|
+
return select(name, **column_info)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydoptic-sql
|
|
3
|
+
Version: 0.0.1.post1.dev2
|
|
4
|
+
Summary: A type-safe SQL query builder built on pydoptic
|
|
5
|
+
Author: John Hungerford
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/johnhungerford/pydoptic
|
|
8
|
+
Requires-Python: <3.14,>=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pydoptic
|
|
12
|
+
Requires-Dist: psycopg[binary]==3.2.12
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest~=8.4.2; extra == "test"
|
|
15
|
+
Provides-Extra: types
|
|
16
|
+
Requires-Dist: mypy~=1.18.2; extra == "types"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pydoptic-sql
|
|
20
|
+
|
|
21
|
+
A type-safe SQL query builder built on [pydoptic](https://pypi.org/project/pydoptic/).
|
|
22
|
+
|
|
23
|
+
Join arity is tracked in the type system itself, so a WHERE/ON/HAVING clause can't reference a table
|
|
24
|
+
that isn't actually in scope at that point in a join chain -- mypy catches it, not just Postgres at
|
|
25
|
+
runtime.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pydoptic-sql
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Includes `psycopg` as a regular dependency, so no extras are needed to execute queries against
|
|
34
|
+
Postgres.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python3
|
|
39
|
+
from pydoptic import Prop
|
|
40
|
+
from pydoptic_sql import SqlTable, ColumnType, PrimaryKey, column, SqlQuery, Constraint, PsycoPgSqlClient
|
|
41
|
+
import psycopg
|
|
42
|
+
|
|
43
|
+
class Worker(SqlTable):
|
|
44
|
+
id: Prop['Worker', int] = column(type=ColumnType.BIGINT(), constraints=[PrimaryKey])
|
|
45
|
+
name: Prop['Worker', str] = column(type=ColumnType.TEXT())
|
|
46
|
+
age: Prop['Worker', int] = column(type=ColumnType.INT())
|
|
47
|
+
department_id: Prop['Worker', int] = column(type=ColumnType.BIGINT())
|
|
48
|
+
|
|
49
|
+
query = SqlQuery.from_table(Worker).select(Worker.name).where(Constraint.gte(Worker.age, 18))
|
|
50
|
+
|
|
51
|
+
with psycopg.connect("host=localhost dbname=mydb user=postgres password=password") as conn:
|
|
52
|
+
client = PsycoPgSqlClient(conn)
|
|
53
|
+
with client.open() as tx:
|
|
54
|
+
for worker in tx.execute(query).stream():
|
|
55
|
+
print(Worker.name.get_val_safe(worker))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Joining a second table narrows and widens the query's type together -- `Constraint2` (not
|
|
59
|
+
`Constraint`) is required for a WHERE/ON clause once a second table is in scope, and can reference
|
|
60
|
+
either table directly:
|
|
61
|
+
|
|
62
|
+
```python3
|
|
63
|
+
class Department(SqlTable):
|
|
64
|
+
id: Prop['Department', int] = column(type=ColumnType.BIGINT(), constraints=[PrimaryKey])
|
|
65
|
+
name: Prop['Department', str] = column(type=ColumnType.TEXT())
|
|
66
|
+
|
|
67
|
+
from pydoptic_sql import Constraint2
|
|
68
|
+
|
|
69
|
+
query = SqlQuery.from_table(Worker).join_inner(
|
|
70
|
+
Department, Constraint2.eq(Worker.department_id, Department.id),
|
|
71
|
+
).select(Worker.name, Department.name).where(Constraint2.gte(Worker.age, 18))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
|
|
75
|
+
the underlying `Prop`/`Select` model this is built on.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pydoptic_sql/__init__.py,sha256=nd5klqFo0xHwqbfsYbMiKYHlcGB-taANcTG4FcgJ0z4,1686
|
|
2
|
+
pydoptic_sql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pydoptic_sql/sql_computed.py,sha256=4rbeWfhMLI7ST9fCHSvjIazuWN0x_6QNDTNggjnH20c,3769
|
|
4
|
+
pydoptic_sql/sql_constraint.py,sha256=hn16pbc6DU_Ig8RLCIdX3nyOPFaKKrQv_-Q0DN3FBRo,37348
|
|
5
|
+
pydoptic_sql/sql_having.py,sha256=V9L0nISf9X8NuMQXIt8-X5wJvqCvcF6itrsz16Z3XS0,43182
|
|
6
|
+
pydoptic_sql/sql_order.py,sha256=y37XRRAZPIvvVPmZPljgP3Ht4Q6Oy-dEEBMvmlKVJk8,1036
|
|
7
|
+
pydoptic_sql/sql_query.py,sha256=UXqy6T9oJQSlhs0e4416jwRcWkyiWUs41Keq6qYvOMw,75539
|
|
8
|
+
pydoptic_sql/sql_service.py,sha256=AsYJcH1zTgzMllp6BchZm9TNYEQfk3SILqs1DRSTy5s,14898
|
|
9
|
+
pydoptic_sql/sql_table.py,sha256=fNpsAqsg68E9HrJV7m0yDIUJu-Tqdo6o7pMSGJvtLQo,4202
|
|
10
|
+
pydoptic_sql-0.0.1.post1.dev2.dist-info/licenses/LICENSE,sha256=mWq1sTRv24YgxnIenVK1P8FNY2YMof3kT7e6D-06KZY,1072
|
|
11
|
+
pydoptic_sql-0.0.1.post1.dev2.dist-info/METADATA,sha256=mslfxHx2lsSJ-gJqiUDcEcoKEo53P1OxKT0KglvKGLA,2678
|
|
12
|
+
pydoptic_sql-0.0.1.post1.dev2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
pydoptic_sql-0.0.1.post1.dev2.dist-info/top_level.txt,sha256=01AdOUMZQBRcEVP-RL26QyHmtsB3yJgvBumE8SUx5Kw,13
|
|
14
|
+
pydoptic_sql-0.0.1.post1.dev2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Hungerford
|
|
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 @@
|
|
|
1
|
+
pydoptic_sql
|