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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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