pydoptic-sql 0.0.1.post1.dev2__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.
- pydoptic_sql-0.0.1.post1.dev2/LICENSE +21 -0
- pydoptic_sql-0.0.1.post1.dev2/PKG-INFO +75 -0
- pydoptic_sql-0.0.1.post1.dev2/README.md +57 -0
- pydoptic_sql-0.0.1.post1.dev2/pyproject.toml +38 -0
- pydoptic_sql-0.0.1.post1.dev2/setup.cfg +4 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/__init__.py +49 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/py.typed +0 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_computed.py +93 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_constraint.py +782 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_having.py +728 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_order.py +28 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_query.py +1144 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_service.py +335 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql/sql_table.py +188 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/PKG-INFO +75 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/SOURCES.txt +25 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/dependency_links.txt +1 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/requires.txt +8 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/scm_file_list.json +22 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/scm_version.json +8 -0
- pydoptic_sql-0.0.1.post1.dev2/src/pydoptic_sql.egg-info/top_level.txt +1 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_computed.py +258 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_constraint.py +296 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_having.py +237 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_params.py +154 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_query.py +911 -0
- pydoptic_sql-0.0.1.post1.dev2/test/test_sql_service.py +657 -0
|
@@ -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,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,57 @@
|
|
|
1
|
+
# pydoptic-sql
|
|
2
|
+
|
|
3
|
+
A type-safe SQL query builder built on [pydoptic](https://pypi.org/project/pydoptic/).
|
|
4
|
+
|
|
5
|
+
Join arity is tracked in the type system itself, so a WHERE/ON/HAVING clause can't reference a table
|
|
6
|
+
that isn't actually in scope at that point in a join chain -- mypy catches it, not just Postgres at
|
|
7
|
+
runtime.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pydoptic-sql
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Includes `psycopg` as a regular dependency, so no extras are needed to execute queries against
|
|
16
|
+
Postgres.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python3
|
|
21
|
+
from pydoptic import Prop
|
|
22
|
+
from pydoptic_sql import SqlTable, ColumnType, PrimaryKey, column, SqlQuery, Constraint, PsycoPgSqlClient
|
|
23
|
+
import psycopg
|
|
24
|
+
|
|
25
|
+
class Worker(SqlTable):
|
|
26
|
+
id: Prop['Worker', int] = column(type=ColumnType.BIGINT(), constraints=[PrimaryKey])
|
|
27
|
+
name: Prop['Worker', str] = column(type=ColumnType.TEXT())
|
|
28
|
+
age: Prop['Worker', int] = column(type=ColumnType.INT())
|
|
29
|
+
department_id: Prop['Worker', int] = column(type=ColumnType.BIGINT())
|
|
30
|
+
|
|
31
|
+
query = SqlQuery.from_table(Worker).select(Worker.name).where(Constraint.gte(Worker.age, 18))
|
|
32
|
+
|
|
33
|
+
with psycopg.connect("host=localhost dbname=mydb user=postgres password=password") as conn:
|
|
34
|
+
client = PsycoPgSqlClient(conn)
|
|
35
|
+
with client.open() as tx:
|
|
36
|
+
for worker in tx.execute(query).stream():
|
|
37
|
+
print(Worker.name.get_val_safe(worker))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Joining a second table narrows and widens the query's type together -- `Constraint2` (not
|
|
41
|
+
`Constraint`) is required for a WHERE/ON clause once a second table is in scope, and can reference
|
|
42
|
+
either table directly:
|
|
43
|
+
|
|
44
|
+
```python3
|
|
45
|
+
class Department(SqlTable):
|
|
46
|
+
id: Prop['Department', int] = column(type=ColumnType.BIGINT(), constraints=[PrimaryKey])
|
|
47
|
+
name: Prop['Department', str] = column(type=ColumnType.TEXT())
|
|
48
|
+
|
|
49
|
+
from pydoptic_sql import Constraint2
|
|
50
|
+
|
|
51
|
+
query = SqlQuery.from_table(Worker).join_inner(
|
|
52
|
+
Department, Constraint2.eq(Worker.department_id, Department.id),
|
|
53
|
+
).select(Worker.name, Department.name).where(Constraint2.gte(Worker.age, 18))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
|
|
57
|
+
the underlying `Prop`/`Select` model this is built on.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pydoptic-sql"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "John Hungerford" }]
|
|
9
|
+
urls = { Source = "https://github.com/johnhungerford/pydoptic" }
|
|
10
|
+
description = "A type-safe SQL query builder built on pydoptic"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.8,<3.14"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"pydoptic",
|
|
17
|
+
"psycopg[binary]==3.2.12",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest~=8.4.2",
|
|
23
|
+
]
|
|
24
|
+
types = [
|
|
25
|
+
"mypy~=1.18.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools_scm]
|
|
29
|
+
# See packages/pydoptic/pyproject.toml -- same repo-wide lockstep version, same reasoning.
|
|
30
|
+
root = "../.."
|
|
31
|
+
version_scheme = "no-guess-dev"
|
|
32
|
+
local_scheme = "no-local-version"
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
markers = [
|
|
36
|
+
"integration: requires a live Postgres instance started via docker-compose.yml; excluded by default, opt in with `-m integration`",
|
|
37
|
+
]
|
|
38
|
+
addopts = "-m 'not integration'"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pydoptic_sql.sql_table import (
|
|
2
|
+
AutoIncrement,
|
|
3
|
+
Check,
|
|
4
|
+
ColumnConstraint,
|
|
5
|
+
ColumnInfo,
|
|
6
|
+
ColumnType,
|
|
7
|
+
Default,
|
|
8
|
+
ForeignKey,
|
|
9
|
+
ManualColumnConstraint,
|
|
10
|
+
PrimaryKey,
|
|
11
|
+
SqlTable,
|
|
12
|
+
Unique,
|
|
13
|
+
column,
|
|
14
|
+
)
|
|
15
|
+
from pydoptic_sql.sql_constraint import Comparison, Constraint, Constraint2, Constraint3, Constraint4
|
|
16
|
+
from pydoptic_sql.sql_order import Direction, OrderBy
|
|
17
|
+
from pydoptic_sql.sql_computed import AggregateFunction, Computed, ComputedResult
|
|
18
|
+
from pydoptic_sql.sql_having import HavingConstraint, HavingConstraint2, HavingConstraint3, HavingConstraint4
|
|
19
|
+
from pydoptic_sql.sql_query import (
|
|
20
|
+
ComputedQuery1,
|
|
21
|
+
ComputedQuery2,
|
|
22
|
+
ComputedQuery3,
|
|
23
|
+
ComputedQuery4,
|
|
24
|
+
CreateQuery,
|
|
25
|
+
DeleteQuery,
|
|
26
|
+
DropQuery,
|
|
27
|
+
InsertQuery,
|
|
28
|
+
JoinType,
|
|
29
|
+
Query1,
|
|
30
|
+
Query2,
|
|
31
|
+
Query3,
|
|
32
|
+
Query4,
|
|
33
|
+
SqlQuery,
|
|
34
|
+
UpdateQuery,
|
|
35
|
+
)
|
|
36
|
+
from pydoptic_sql.sql_service import PsycoPgSqlClient, SqlClient, SqlResponse, SqlTransaction
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
'AutoIncrement', 'Check', 'ColumnConstraint', 'ColumnInfo', 'ColumnType', 'Default', 'ForeignKey',
|
|
40
|
+
'ManualColumnConstraint', 'PrimaryKey', 'SqlTable', 'Unique', 'column',
|
|
41
|
+
'Comparison', 'Constraint', 'Constraint2', 'Constraint3', 'Constraint4',
|
|
42
|
+
'Direction', 'OrderBy',
|
|
43
|
+
'AggregateFunction', 'Computed', 'ComputedResult',
|
|
44
|
+
'HavingConstraint', 'HavingConstraint2', 'HavingConstraint3', 'HavingConstraint4',
|
|
45
|
+
'ComputedQuery1', 'ComputedQuery2', 'ComputedQuery3', 'ComputedQuery4',
|
|
46
|
+
'CreateQuery', 'DeleteQuery', 'DropQuery', 'InsertQuery', 'JoinType',
|
|
47
|
+
'Query1', 'Query2', 'Query3', 'Query4', 'SqlQuery', 'UpdateQuery',
|
|
48
|
+
'PsycoPgSqlClient', 'SqlClient', 'SqlResponse', 'SqlTransaction',
|
|
49
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Dict, Generic, Mapping, Type, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydoptic.selector import Prop, PropOpt, SelectVal, SelectValue, Selectable
|
|
7
|
+
from pydoptic_sql import SqlTable
|
|
8
|
+
|
|
9
|
+
TC = TypeVar('TC', bound=SqlTable, contravariant=True)
|
|
10
|
+
A = TypeVar('A')
|
|
11
|
+
|
|
12
|
+
class AggregateFunction(Enum):
|
|
13
|
+
SUM = 'SUM'
|
|
14
|
+
COUNT = 'COUNT'
|
|
15
|
+
AVG = 'AVG'
|
|
16
|
+
MIN = 'MIN'
|
|
17
|
+
MAX = 'MAX'
|
|
18
|
+
|
|
19
|
+
class ComputedResult(Selectable['ComputedResult']):
|
|
20
|
+
"""
|
|
21
|
+
Dict-backed holder for the computed (aggregate) columns of a query result row, keyed by each
|
|
22
|
+
`Computed`'s alias. Mirrors `PartialModel`'s shape but isn't backed by a model class -- there's
|
|
23
|
+
no schema behind an aggregate expression, just whatever aliases the query selected.
|
|
24
|
+
"""
|
|
25
|
+
__slots__ = ['_dict']
|
|
26
|
+
_dict: Dict[str, Any]
|
|
27
|
+
|
|
28
|
+
def __init__(self, **kwargs: Any):
|
|
29
|
+
object.__setattr__(self, '_dict', dict(kwargs))
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return 'ComputedResult(' + ', '.join(f'{k}={v}' for k, v in self._dict.items()) + ')'
|
|
33
|
+
|
|
34
|
+
def __eq__(self, other: object) -> bool:
|
|
35
|
+
return isinstance(other, ComputedResult) and self._dict == other._dict
|
|
36
|
+
|
|
37
|
+
def __getattr__(self, item: str) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
return object.__getattribute__(self, '_dict')[item]
|
|
40
|
+
except KeyError:
|
|
41
|
+
raise AttributeError(item)
|
|
42
|
+
|
|
43
|
+
def as_dict(self) -> Mapping[str, Any]:
|
|
44
|
+
return self._dict
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Computed(Generic[TC, A], SelectVal[ComputedResult, A]):
|
|
48
|
+
"""
|
|
49
|
+
An aggregate expression (SUM/COUNT/AVG/MIN/MAX) over a column, constructed via `SqlQuery.sum`/
|
|
50
|
+
`.count`/etc., selected into a query via `select_computed`/`select_computed_more`, and extracted
|
|
51
|
+
from a `ComputedResult` via `get_val`/`get_val_safe` -- same as a plain `Prop` extracts a value
|
|
52
|
+
from a model. `column` is `None` only for `SqlQuery.count(table)` (`COUNT(*)`), which doesn't
|
|
53
|
+
reference any particular column.
|
|
54
|
+
|
|
55
|
+
`TC` is phantom bookkeeping, not part of the `Select[ComputedResult, A]` shape it's used as --
|
|
56
|
+
it exists purely so `select_computed_more`'s signature can restrict which joined table(s) a given
|
|
57
|
+
`Computed` may reference, the same way `OrderBy`'s `TC` does. Like `OrderBy`, `Computed` has no
|
|
58
|
+
arity variants of its own: a joined query's computed selection is typed as a union of
|
|
59
|
+
`Computed[<each joined table>, Any]`, so an entry set for one table stays valid unchanged as more
|
|
60
|
+
tables get joined in, and qualification (`table.column` vs. bare) is decided by whichever class
|
|
61
|
+
renders it, not by `Computed` itself.
|
|
62
|
+
"""
|
|
63
|
+
column: Prop[TC, Any] | PropOpt[TC, Any] | None
|
|
64
|
+
function: AggregateFunction
|
|
65
|
+
label: str
|
|
66
|
+
_target: Type[A]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def origin(self) -> Type[ComputedResult]:
|
|
70
|
+
return ComputedResult
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def target(self) -> Type[A]:
|
|
74
|
+
return self._target
|
|
75
|
+
|
|
76
|
+
def get_unsafe(self, value: 'Selectable[ComputedResult] | Dict[str, Any]') -> SelectValue[A]:
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
if self.label not in value:
|
|
79
|
+
raise ValueError(f'Unexpected empty value for computed column {self.label}')
|
|
80
|
+
result = value[self.label]
|
|
81
|
+
else:
|
|
82
|
+
try:
|
|
83
|
+
result = getattr(value, self.label)
|
|
84
|
+
except AttributeError:
|
|
85
|
+
raise ValueError(f'Unexpected empty value for computed column {self.label}')
|
|
86
|
+
return SelectValue(result, False, False)
|
|
87
|
+
|
|
88
|
+
def get(self, value: ComputedResult) -> SelectValue[A]:
|
|
89
|
+
return SelectValue(getattr(value, self.label), False, False)
|
|
90
|
+
|
|
91
|
+
def to_sql(self) -> str:
|
|
92
|
+
col_ref = '*' if self.column is None else self.column.label
|
|
93
|
+
return f'{self.function.value}({col_ref}) AS {self.label}'
|