sqlproof 0.1.0a1__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.
- sqlproof/__init__.py +32 -0
- sqlproof/_version.py +1 -0
- sqlproof/cli.py +151 -0
- sqlproof/client.py +159 -0
- sqlproof/config.py +42 -0
- sqlproof/contrib/__init__.py +3 -0
- sqlproof/contrib/supabase.py +136 -0
- sqlproof/core.py +344 -0
- sqlproof/coverage/__init__.py +6 -0
- sqlproof/coverage/diversity.py +11 -0
- sqlproof/coverage/plpgsql.py +5 -0
- sqlproof/coverage/schema_shape.py +7 -0
- sqlproof/exceptions.py +47 -0
- sqlproof/generators/__init__.py +21 -0
- sqlproof/generators/columns.py +93 -0
- sqlproof/generators/constraints.py +181 -0
- sqlproof/generators/functions.py +9 -0
- sqlproof/generators/graph.py +51 -0
- sqlproof/generators/rows.py +153 -0
- sqlproof/generators/sampling.py +15 -0
- sqlproof/generators/well_known.py +59 -0
- sqlproof/pytest_plugin.py +24 -0
- sqlproof/reporter/__init__.py +5 -0
- sqlproof/reporter/console.py +20 -0
- sqlproof/reporter/json_io.py +26 -0
- sqlproof/runners/__init__.py +14 -0
- sqlproof/runners/db.py +48 -0
- sqlproof/runners/migration.py +51 -0
- sqlproof/runners/overload.py +41 -0
- sqlproof/runners/property.py +119 -0
- sqlproof/runners/rls.py +40 -0
- sqlproof/runners/stateful.py +36 -0
- sqlproof/schema/__init__.py +27 -0
- sqlproof/schema/dependency_graph.py +38 -0
- sqlproof/schema/fingerprint.py +34 -0
- sqlproof/schema/introspect.py +229 -0
- sqlproof/schema/model.py +98 -0
- sqlproof/schema/parse_sql.py +206 -0
- sqlproof/testing.py +101 -0
- sqlproof/types.py +34 -0
- sqlproof-0.1.0a1.dist-info/METADATA +248 -0
- sqlproof-0.1.0a1.dist-info/RECORD +44 -0
- sqlproof-0.1.0a1.dist-info/WHEEL +4 -0
- sqlproof-0.1.0a1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal, cast
|
|
5
|
+
|
|
6
|
+
from pglast import parse_sql as parse_postgres_sql
|
|
7
|
+
from pglast.enums import ConstrType
|
|
8
|
+
from pglast.stream import RawStream
|
|
9
|
+
|
|
10
|
+
from sqlproof.exceptions import SqlProofSchemaError
|
|
11
|
+
from sqlproof.schema.model import CheckConstraint, Column, ForeignKey, PgType, SchemaInfo, Table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_schema_sql(sql: str, *, schema: str = "public") -> SchemaInfo:
|
|
15
|
+
try:
|
|
16
|
+
statements: tuple[Any, ...] = tuple(parse_postgres_sql(sql))
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
raise SqlProofSchemaError(str(exc)) from exc
|
|
19
|
+
|
|
20
|
+
enums: list[PgType] = []
|
|
21
|
+
enum_names: dict[str, PgType] = {}
|
|
22
|
+
for raw_statement in statements:
|
|
23
|
+
statement: Any = raw_statement.stmt
|
|
24
|
+
if type(statement).__name__ == "CreateEnumStmt":
|
|
25
|
+
enum_schema, enum_name = _qualified_parts(statement.typeName, default_schema=schema)
|
|
26
|
+
enum = PgType(
|
|
27
|
+
kind="enum",
|
|
28
|
+
name=enum_name,
|
|
29
|
+
enum_values=tuple(_sval(value) for value in statement.vals),
|
|
30
|
+
)
|
|
31
|
+
enums.append(enum)
|
|
32
|
+
enum_names[enum_name] = enum
|
|
33
|
+
enum_names[f"{enum_schema}.{enum_name}"] = enum
|
|
34
|
+
|
|
35
|
+
tables = tuple(
|
|
36
|
+
_parse_table_statement(raw_statement.stmt, enum_names, schema)
|
|
37
|
+
for raw_statement in statements
|
|
38
|
+
if type(raw_statement.stmt).__name__ == "CreateStmt"
|
|
39
|
+
)
|
|
40
|
+
if not tables and "CREATE TABLE" in sql.upper():
|
|
41
|
+
raise SqlProofSchemaError("Could not parse CREATE TABLE statement.")
|
|
42
|
+
return SchemaInfo(tables=tables, enums=tuple(enums))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_table_statement(statement: Any, enum_names: dict[str, PgType], schema: str) -> Table:
|
|
46
|
+
relation = statement.relation
|
|
47
|
+
table_schema = relation.schemaname or schema
|
|
48
|
+
table_name = relation.relname
|
|
49
|
+
columns: list[Column] = []
|
|
50
|
+
primary_key: tuple[str, ...] = ()
|
|
51
|
+
foreign_keys: list[ForeignKey] = []
|
|
52
|
+
unique_constraints: list[tuple[str, ...]] = []
|
|
53
|
+
check_constraints: list[CheckConstraint] = []
|
|
54
|
+
|
|
55
|
+
for element in statement.tableElts or ():
|
|
56
|
+
if type(element).__name__ == "ColumnDef":
|
|
57
|
+
column = _parse_column(element, enum_names)
|
|
58
|
+
columns.append(column)
|
|
59
|
+
for constraint in element.constraints or ():
|
|
60
|
+
if constraint.contype == ConstrType.CONSTR_PRIMARY:
|
|
61
|
+
primary_key = (column.name,)
|
|
62
|
+
elif constraint.contype == ConstrType.CONSTR_UNIQUE:
|
|
63
|
+
unique_constraints.append((column.name,))
|
|
64
|
+
elif constraint.contype == ConstrType.CONSTR_FOREIGN:
|
|
65
|
+
foreign_keys.append(_parse_foreign_key(constraint, columns=(column.name,)))
|
|
66
|
+
elif constraint.contype == ConstrType.CONSTR_CHECK:
|
|
67
|
+
check_constraints.append(_parse_check(constraint))
|
|
68
|
+
continue
|
|
69
|
+
if element.contype == ConstrType.CONSTR_PRIMARY:
|
|
70
|
+
primary_key = _constraint_keys(element)
|
|
71
|
+
elif element.contype == ConstrType.CONSTR_UNIQUE:
|
|
72
|
+
unique_constraints.append(_constraint_keys(element))
|
|
73
|
+
elif element.contype == ConstrType.CONSTR_FOREIGN:
|
|
74
|
+
foreign_keys.append(_parse_foreign_key(element))
|
|
75
|
+
elif element.contype == ConstrType.CONSTR_CHECK:
|
|
76
|
+
check_constraints.append(_parse_check(element))
|
|
77
|
+
|
|
78
|
+
return Table(
|
|
79
|
+
schema=table_schema,
|
|
80
|
+
name=table_name,
|
|
81
|
+
columns=tuple(columns),
|
|
82
|
+
primary_key=primary_key,
|
|
83
|
+
foreign_keys=tuple(foreign_keys),
|
|
84
|
+
unique_constraints=tuple(unique_constraints),
|
|
85
|
+
check_constraints=tuple(check_constraints),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_column(column: Any, enum_names: dict[str, PgType]) -> Column:
|
|
90
|
+
constraints = tuple(column.constraints or ())
|
|
91
|
+
pg_type = _parse_type_node(column.typeName, enum_names)
|
|
92
|
+
identity = _identity_for_constraints(constraints)
|
|
93
|
+
primary = any(constraint.contype == ConstrType.CONSTR_PRIMARY for constraint in constraints)
|
|
94
|
+
not_null = (
|
|
95
|
+
column.is_not_null
|
|
96
|
+
or primary
|
|
97
|
+
or any(constraint.contype == ConstrType.CONSTR_NOTNULL for constraint in constraints)
|
|
98
|
+
)
|
|
99
|
+
return Column(
|
|
100
|
+
name=column.colname,
|
|
101
|
+
type=pg_type,
|
|
102
|
+
nullable=not not_null,
|
|
103
|
+
default=_default_for_constraints(constraints),
|
|
104
|
+
is_generated=pg_type.name in {"serial", "bigserial"} or identity is not None,
|
|
105
|
+
identity=identity,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_type_node(type_node: Any, enum_names: dict[str, PgType]) -> PgType:
|
|
110
|
+
parts = tuple(_sval(part) for part in type_node.names)
|
|
111
|
+
name = _normalize_type_name(".".join(parts))
|
|
112
|
+
if name in enum_names:
|
|
113
|
+
return enum_names[name]
|
|
114
|
+
unqualified = name.rsplit(".", 1)[-1]
|
|
115
|
+
if unqualified in enum_names:
|
|
116
|
+
return enum_names[unqualified]
|
|
117
|
+
modifiers = tuple(_const_int(modifier) for modifier in type_node.typmods or ())
|
|
118
|
+
return PgType(kind="scalar", name=unqualified, modifiers=modifiers)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_foreign_key(constraint: Any, *, columns: tuple[str, ...] | None = None) -> ForeignKey:
|
|
122
|
+
return ForeignKey(
|
|
123
|
+
columns=columns or tuple(_sval(value) for value in constraint.fk_attrs),
|
|
124
|
+
referenced_table=constraint.pktable.relname,
|
|
125
|
+
referenced_columns=tuple(_sval(value) for value in constraint.pk_attrs),
|
|
126
|
+
on_delete=_referential_action(constraint.fk_del_action),
|
|
127
|
+
on_update=_referential_action(constraint.fk_upd_action),
|
|
128
|
+
referenced_schema=getattr(constraint.pktable, "schemaname", None),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_check(constraint: Any) -> CheckConstraint:
|
|
133
|
+
return CheckConstraint(_render(constraint.raw_expr))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _constraint_keys(constraint: Any) -> tuple[str, ...]:
|
|
137
|
+
return tuple(_sval(value) for value in constraint.keys)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _default_for_constraints(constraints: tuple[Any, ...]) -> str | None:
|
|
141
|
+
for constraint in constraints:
|
|
142
|
+
if constraint.contype == ConstrType.CONSTR_DEFAULT:
|
|
143
|
+
return _render(constraint.raw_expr)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _identity_for_constraints(
|
|
148
|
+
constraints: tuple[Any, ...],
|
|
149
|
+
) -> Literal["always", "by_default"] | None:
|
|
150
|
+
for constraint in constraints:
|
|
151
|
+
if constraint.contype == ConstrType.CONSTR_IDENTITY:
|
|
152
|
+
if constraint.generated_when == "a":
|
|
153
|
+
return "always"
|
|
154
|
+
if constraint.generated_when == "d":
|
|
155
|
+
return "by_default"
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _referential_action(
|
|
160
|
+
action: str,
|
|
161
|
+
) -> Literal["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"]:
|
|
162
|
+
return cast(
|
|
163
|
+
Literal["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"],
|
|
164
|
+
{
|
|
165
|
+
"a": "NO ACTION",
|
|
166
|
+
"r": "RESTRICT",
|
|
167
|
+
"c": "CASCADE",
|
|
168
|
+
"n": "SET NULL",
|
|
169
|
+
"d": "SET DEFAULT",
|
|
170
|
+
"\x00": "NO ACTION",
|
|
171
|
+
}.get(action, "NO ACTION"),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _qualified_parts(parts: tuple[Any, ...], *, default_schema: str) -> tuple[str, str]:
|
|
176
|
+
values = tuple(_sval(part) for part in parts)
|
|
177
|
+
if len(values) == 1:
|
|
178
|
+
return default_schema, values[0]
|
|
179
|
+
return values[-2], values[-1]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sval(node: Any) -> str:
|
|
183
|
+
return str(node.sval)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _const_int(node: Any) -> int:
|
|
187
|
+
return int(node.val.ival)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _render(node: Any) -> str:
|
|
191
|
+
stream = RawStream() # type: ignore[no-untyped-call]
|
|
192
|
+
return str(stream(node))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _normalize_type_name(name: str) -> str:
|
|
196
|
+
normalized = re.sub(r"\s+", " ", name.lower())
|
|
197
|
+
return {
|
|
198
|
+
"pg_catalog.int2": "smallint",
|
|
199
|
+
"pg_catalog.int4": "integer",
|
|
200
|
+
"pg_catalog.int8": "bigint",
|
|
201
|
+
"pg_catalog.float4": "real",
|
|
202
|
+
"pg_catalog.float8": "double precision",
|
|
203
|
+
"pg_catalog.bool": "boolean",
|
|
204
|
+
"pg_catalog.varchar": "varchar",
|
|
205
|
+
"pg_catalog.bpchar": "char",
|
|
206
|
+
}.get(normalized, normalized)
|
sqlproof/testing.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import AbstractContextManager, ExitStack
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
|
|
6
|
+
|
|
7
|
+
from hypothesis import strategies as st
|
|
8
|
+
from hypothesis.stateful import RuleBasedStateMachine
|
|
9
|
+
from hypothesis.strategies import SearchStrategy
|
|
10
|
+
|
|
11
|
+
from sqlproof.exceptions import SqlProofUsageError
|
|
12
|
+
from sqlproof.generators.graph import Dataset, SizeSpec, dataset_strategy
|
|
13
|
+
from sqlproof.schema.model import Column, PgType, SchemaInfo, Table
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from sqlproof.client import SqlProofClient
|
|
17
|
+
from sqlproof.core import SqlProof
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SqlProofStateMachine(RuleBasedStateMachine):
|
|
23
|
+
"""Base class for Hypothesis stateful tests against a SqlProof database.
|
|
24
|
+
|
|
25
|
+
Each example leases an isolated client via `proof.client_for_dataset(...)`
|
|
26
|
+
so that writes from one example are rolled back before the next begins.
|
|
27
|
+
Subclasses define `@rule`s and `@invariant`s as usual, and override
|
|
28
|
+
`on_setup()` for per-example fixture creation. `self.db` is the live
|
|
29
|
+
`SqlProofClient`.
|
|
30
|
+
|
|
31
|
+
Run a machine with `SqlProof.run_state_machine(MyMachine, settings=...)`,
|
|
32
|
+
which binds the proof and dispatches to `run_state_machine_as_test`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
initial_dataset: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
36
|
+
_sqlproof_proof: ClassVar[SqlProof | None] = None
|
|
37
|
+
|
|
38
|
+
db: SqlProofClient
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
if self._sqlproof_proof is None:
|
|
42
|
+
msg = (
|
|
43
|
+
"SqlProofStateMachine cannot be instantiated directly. "
|
|
44
|
+
"Use SqlProof.run_state_machine(YourMachine) to run it."
|
|
45
|
+
)
|
|
46
|
+
raise SqlProofUsageError(msg)
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._stack = ExitStack()
|
|
49
|
+
self.db = self._stack.enter_context(
|
|
50
|
+
self._sqlproof_proof.client_for_dataset(dict(self.initial_dataset))
|
|
51
|
+
)
|
|
52
|
+
self.on_setup()
|
|
53
|
+
|
|
54
|
+
def on_setup(self) -> None:
|
|
55
|
+
"""Override to seed per-example fixtures. `self.db` is ready."""
|
|
56
|
+
|
|
57
|
+
def enter(self, cm: AbstractContextManager[_T]) -> _T:
|
|
58
|
+
"""Enter `cm` and tie its lifetime to this example.
|
|
59
|
+
|
|
60
|
+
Use for resources that need to live across rules within an example
|
|
61
|
+
and be released between examples — JWT-claim contexts, savepoints,
|
|
62
|
+
mocked clocks, etc. The context manager is closed during `teardown`
|
|
63
|
+
in reverse-entry order.
|
|
64
|
+
"""
|
|
65
|
+
return self._stack.enter_context(cm)
|
|
66
|
+
|
|
67
|
+
def teardown(self) -> None:
|
|
68
|
+
if hasattr(self, "_stack"):
|
|
69
|
+
self._stack.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def schemas(max_tables: int = 3, max_columns: int = 5) -> SearchStrategy[SchemaInfo]:
|
|
73
|
+
del max_columns
|
|
74
|
+
table_names = st.lists(
|
|
75
|
+
st.sampled_from(["users", "orders", "products", "scores", "events"]),
|
|
76
|
+
min_size=1,
|
|
77
|
+
max_size=max_tables,
|
|
78
|
+
unique=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def build(names: list[str]) -> SchemaInfo:
|
|
82
|
+
integer = PgType("scalar", "integer")
|
|
83
|
+
tables = tuple(
|
|
84
|
+
Table(
|
|
85
|
+
schema="public",
|
|
86
|
+
name=name,
|
|
87
|
+
columns=(Column("id", integer, False, None, False),),
|
|
88
|
+
primary_key=("id",),
|
|
89
|
+
foreign_keys=(),
|
|
90
|
+
unique_constraints=(),
|
|
91
|
+
check_constraints=(),
|
|
92
|
+
)
|
|
93
|
+
for name in names
|
|
94
|
+
)
|
|
95
|
+
return SchemaInfo(tables=tables)
|
|
96
|
+
|
|
97
|
+
return table_names.map(build)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def datasets_for(schema: SchemaInfo, sizes: Mapping[str, SizeSpec]) -> SearchStrategy[Dataset]:
|
|
101
|
+
return dataset_strategy(schema, sizes=sizes)
|
sqlproof/types.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from sqlproof.schema.parse_sql import parse_schema_sql
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_types(schema_file: Path, *, style: str = "typeddict") -> str:
|
|
9
|
+
schema = parse_schema_sql(schema_file.read_text(encoding="utf-8"))
|
|
10
|
+
lines = ["from __future__ import annotations"]
|
|
11
|
+
if style == "typeddict":
|
|
12
|
+
lines.append("from typing import TypedDict")
|
|
13
|
+
for table in schema.tables:
|
|
14
|
+
class_name = _class_name(table.name)
|
|
15
|
+
lines.extend(["", f"class {class_name}(TypedDict):"])
|
|
16
|
+
for column in table.columns:
|
|
17
|
+
lines.append(f" {column.name}: object")
|
|
18
|
+
elif style == "dataclass":
|
|
19
|
+
lines.append("from dataclasses import dataclass")
|
|
20
|
+
for table in schema.tables:
|
|
21
|
+
lines.extend(["", "@dataclass", f"class {_class_name(table.name)}:"])
|
|
22
|
+
for column in table.columns:
|
|
23
|
+
lines.append(f" {column.name}: object")
|
|
24
|
+
else:
|
|
25
|
+
lines.append("from pydantic import BaseModel")
|
|
26
|
+
for table in schema.tables:
|
|
27
|
+
lines.extend(["", f"class {_class_name(table.name)}(BaseModel):"])
|
|
28
|
+
for column in table.columns:
|
|
29
|
+
lines.append(f" {column.name}: object")
|
|
30
|
+
return "\n".join(lines) + "\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _class_name(table_name: str) -> str:
|
|
34
|
+
return "".join(part.capitalize() for part in table_name.split("_")).removesuffix("s")
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlproof
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Property-based testing for PostgreSQL schemas and SQL behavior. Early-stage alpha — APIs may change.
|
|
5
|
+
Project-URL: Homepage, https://sqlproof.com
|
|
6
|
+
Project-URL: Documentation, https://sqlproof.com
|
|
7
|
+
Project-URL: Repository, https://github.com/alialavia/sqlproof
|
|
8
|
+
Project-URL: Issues, https://github.com/alialavia/sqlproof/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/alialavia/sqlproof/blob/main/CHANGELOG.md
|
|
10
|
+
Author: SqlProof contributors
|
|
11
|
+
License: MIT
|
|
12
|
+
Keywords: hypothesis,postgres,postgresql,property-based-testing,rls,sql,testing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Hypothesis
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Database :: Database Engines/Servers
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: hypothesis>=6.100
|
|
30
|
+
Requires-Dist: pglast>=6.0
|
|
31
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
32
|
+
Requires-Dist: rich>=13.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: mutmut; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
36
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-benchmark; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-xdist; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
42
|
+
Requires-Dist: syrupy; extra == 'dev'
|
|
43
|
+
Requires-Dist: uv; extra == 'dev'
|
|
44
|
+
Provides-Extra: pydantic
|
|
45
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
46
|
+
Provides-Extra: testcontainers
|
|
47
|
+
Requires-Dist: testcontainers[postgres]>=4.0; extra == 'testcontainers'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# SqlProof
|
|
51
|
+
|
|
52
|
+
[](https://github.com/alialavia/sqlproof/actions/workflows/ci.yml)
|
|
53
|
+
[](https://codecov.io/gh/alialavia/sqlproof)
|
|
54
|
+
[](https://pypi.org/project/sqlproof/)
|
|
55
|
+
|
|
56
|
+
**→ Full docs: [sqlproof.com](https://sqlproof.com)**
|
|
57
|
+
|
|
58
|
+
> ⚠️ **Early-stage alpha (`0.1.0a1`).** APIs are unstable and may change without
|
|
59
|
+
> deprecation warnings until 0.x stabilizes. Postgres edge cases and Hypothesis
|
|
60
|
+
> shrink behavior are still being discovered, and coverage of the schema surface
|
|
61
|
+
> area is incomplete. **Do not rely on this for production test suites yet.**
|
|
62
|
+
> Bug reports and reproductions welcome —
|
|
63
|
+
> [open an issue](https://github.com/alialavia/sqlproof/issues).
|
|
64
|
+
|
|
65
|
+
Property-based testing for PostgreSQL schemas and SQL behavior. Define properties about
|
|
66
|
+
your database code; SqlProof generates valid datasets with Hypothesis, executes your
|
|
67
|
+
queries through `psycopg`, and saves the smallest counterexample it finds.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
Alpha releases are gated behind a pre-release flag so you don't get one by accident:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install --pre sqlproof
|
|
75
|
+
# or:
|
|
76
|
+
uv add --prerelease=allow sqlproof
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+ and PostgreSQL 13+.
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
Given a schema file:
|
|
84
|
+
|
|
85
|
+
```sql
|
|
86
|
+
-- schema.sql
|
|
87
|
+
CREATE TABLE orders (
|
|
88
|
+
id SERIAL PRIMARY KEY,
|
|
89
|
+
customer_id INTEGER NOT NULL,
|
|
90
|
+
total NUMERIC(10,2) NOT NULL CHECK (total >= 0)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE line_items (
|
|
94
|
+
id SERIAL PRIMARY KEY,
|
|
95
|
+
order_id INTEGER NOT NULL REFERENCES orders(id),
|
|
96
|
+
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
|
97
|
+
price NUMERIC(10,2) NOT NULL CHECK (price > 0)
|
|
98
|
+
);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Write property tests with pytest:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from sqlproof import SqlProof, sqlproof
|
|
105
|
+
|
|
106
|
+
proof = SqlProof.from_schema_file("./schema.sql")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@sqlproof(proof, sizes={"orders": 20, "line_items": 50}, runs=50)
|
|
110
|
+
def test_no_orphan_line_items(db):
|
|
111
|
+
rows = db.query("""
|
|
112
|
+
SELECT li.id
|
|
113
|
+
FROM line_items li
|
|
114
|
+
LEFT JOIN orders o ON li.order_id = o.id
|
|
115
|
+
WHERE o.id IS NULL
|
|
116
|
+
""")
|
|
117
|
+
assert rows == []
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
SqlProof will:
|
|
121
|
+
|
|
122
|
+
1. Parse your schema (tables, columns, FKs, CHECK constraints, enums)
|
|
123
|
+
2. Topologically order tables by foreign-key dependencies
|
|
124
|
+
3. Generate datasets that respect common type, FK, CHECK, UNIQUE, and NOT NULL constraints
|
|
125
|
+
4. Run your property with Hypothesis-managed execution and shrinking
|
|
126
|
+
5. Save the shrunk counterexample as JSON when a property fails
|
|
127
|
+
|
|
128
|
+
## API
|
|
129
|
+
|
|
130
|
+
See the full API reference at [sqlproof.com](https://sqlproof.com).
|
|
131
|
+
|
|
132
|
+
### Quick reference
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
proof = SqlProof.from_schema_file("./schema.sql")
|
|
136
|
+
proof = SqlProof.from_connection_string("postgresql://localhost/postgres")
|
|
137
|
+
|
|
138
|
+
proof.check("name", sizes={"orders": 10}, property=lambda db: ...)
|
|
139
|
+
proof.invariant(
|
|
140
|
+
"no bad rows",
|
|
141
|
+
sizes={"orders": 10},
|
|
142
|
+
query="SELECT id FROM orders WHERE total < 0",
|
|
143
|
+
expect_empty=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
proof.disconnect()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Schema Sources
|
|
150
|
+
|
|
151
|
+
**SQL file** — SqlProof parses `CREATE TABLE`, `CREATE TYPE ... AS ENUM`, foreign keys, CHECK constraints, UNIQUE constraints, and column types directly from `.sql` files.
|
|
152
|
+
|
|
153
|
+
**Connection string** — Pass a `postgresql://` URL and SqlProof introspects the live database via `information_schema` and `pg_catalog`.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
proof = SqlProof.from_connection_string("postgresql://localhost:5432/mydb")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Custom Column Generators
|
|
160
|
+
|
|
161
|
+
SqlProof maps PostgreSQL types to Hypothesis strategies and refines simple range,
|
|
162
|
+
`IN (...)`, length, FK, and unique constraints. The public customization API is present
|
|
163
|
+
for v0.1 and will grow with richer per-column strategy overrides.
|
|
164
|
+
|
|
165
|
+
### The `db` Client
|
|
166
|
+
|
|
167
|
+
The property function receives a `SqlProofClient`:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
rows = db.query("SELECT id, total FROM orders WHERE total >= %s", 0)
|
|
171
|
+
total = db.scalar("SELECT count(*) FROM orders")
|
|
172
|
+
typed = db.query_typed("SELECT id, total FROM orders", OrderRow)
|
|
173
|
+
data = db.get_generated_data()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- `query()` returns a list of dictionaries.
|
|
177
|
+
- `query_typed()` maps rows into `TypedDict`, dataclass, or Pydantic models.
|
|
178
|
+
- `get_generated_data()` returns the dataset for the current run.
|
|
179
|
+
|
|
180
|
+
## Failure Output
|
|
181
|
+
|
|
182
|
+
When a property fails, SqlProof throws with a formatted counterexample:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
Property failed: order totals match sum of line items
|
|
186
|
+
Failure: AssertionError: expected totals to match
|
|
187
|
+
Row context: {'order_id': 1}
|
|
188
|
+
Dataset shape: {'orders': {'rows': 1}, 'line_items': {'rows': 2}}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Counterexamples are written under `.sqlproof/failures/` and can be inspected with:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
sqlproof report .sqlproof/failures/test_name.json
|
|
195
|
+
sqlproof report .sqlproof/failures/test_name.json --format json
|
|
196
|
+
sqlproof replay .sqlproof/failures/test_name.json
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## How It Works
|
|
200
|
+
|
|
201
|
+
1. **Schema parsing** — Reads your SQL file (or introspects a live DB) to extract tables, columns, types, foreign keys, CHECK/UNIQUE constraints, and enums
|
|
202
|
+
|
|
203
|
+
2. **Dependency ordering** — Topologically sorts tables by foreign key references so parent rows are always inserted first
|
|
204
|
+
|
|
205
|
+
3. **Data generation** — Maps PostgreSQL types to Hypothesis strategies and applies constraint-aware generation for CHECK, UNIQUE, NOT NULL, and FK constraints
|
|
206
|
+
|
|
207
|
+
4. **Isolated execution** — Schema-file proofs run against an in-memory client for fast local feedback. DSN-backed proofs introspect PostgreSQL, insert generated data inside savepoints, run the property, then roll back the run.
|
|
208
|
+
|
|
209
|
+
5. **Shrinking** — When a property fails, Hypothesis shrinks the dataset to find the simplest counterexample
|
|
210
|
+
|
|
211
|
+
## Supported PostgreSQL Types
|
|
212
|
+
|
|
213
|
+
`integer`, `smallint`, `bigint`, `serial`, `bigserial`, `numeric(p,s)`, `real`, `double precision`, `boolean`, `text`, `varchar(n)`, `char(n)`, `uuid`, `timestamp`, `timestamptz`, `date`, `time`, `json`, `jsonb`, `bytea`, and custom `ENUM` types.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
git clone https://github.com/your-org/sqlproof.git
|
|
219
|
+
cd sqlproof
|
|
220
|
+
uv sync --extra dev
|
|
221
|
+
|
|
222
|
+
uv run pytest
|
|
223
|
+
uv run ruff check src tests
|
|
224
|
+
uv run pyright src/sqlproof
|
|
225
|
+
uv run mypy src/sqlproof
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Postgres-backed tests
|
|
229
|
+
|
|
230
|
+
Integration tests are optional and read `SQLPROOF_TEST_DATABASE_URL`:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
SQLPROOF_TEST_DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:5432/postgres' uv run pytest tests/integration
|
|
234
|
+
uv run pytest tests/benchmarks
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The integration tests create a temporary schema named `sqlproof_it_*` and drop it at the end.
|
|
238
|
+
|
|
239
|
+
### Why SqlProof tests itself with properties
|
|
240
|
+
|
|
241
|
+
SqlProof uses Hypothesis internally, and its own tests use properties for schema
|
|
242
|
+
fingerprinting, dependency ordering, FK validity, constraint generation, shrinking,
|
|
243
|
+
parser idempotence, and counterexample replay. This keeps the library honest about
|
|
244
|
+
the same invariants it asks users to write.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
sqlproof/__init__.py,sha256=aweqOOTRvEusJ2wvHgojpnBWZtxrheQ39J1GkcfceGM,871
|
|
2
|
+
sqlproof/_version.py,sha256=lmvUfjeqbjbsnD2PLlUjZbqEGZrq6yUD6t2LZsM61ak,24
|
|
3
|
+
sqlproof/cli.py,sha256=24QggNSyi71nwrV_AJ6fTBoUSCbFud5FjhUZQi-W5N0,5516
|
|
4
|
+
sqlproof/client.py,sha256=5rZn2OTDfMMvcPGkLVaj5R_lD4rwunptUgpqqMakjU0,5604
|
|
5
|
+
sqlproof/config.py,sha256=ASE8RXF8bWeDdR8_ys1bBOdAbdqp8ItwsXP8iZnCj0I,1375
|
|
6
|
+
sqlproof/core.py,sha256=j_bV9bxwWlTKSwdjjJ5XnjG_1wEvlOReuMTXJUAAZW0,11789
|
|
7
|
+
sqlproof/exceptions.py,sha256=0lYb9pYsxtU6Or_JP2YYvLkbUJ024VeWMUiFtU2YiLw,1168
|
|
8
|
+
sqlproof/pytest_plugin.py,sha256=0MbhCh7Sl5Pn5eD3QTRTjlpsAICxXJaKUAq8NEtH1Ug,942
|
|
9
|
+
sqlproof/testing.py,sha256=X1FtKVMl_OILrQWlgUwpZ2aXVq56mF6Td7ZOW3xj6NQ,3551
|
|
10
|
+
sqlproof/types.py,sha256=-MXEhHjd0vqPnVi5QYOeIQmsVimQP2qV_vyRq0I09ww,1404
|
|
11
|
+
sqlproof/contrib/__init__.py,sha256=eEYYkxX5Hz9woXVOBJ2H2_CQoEih0vH6nRt3sH2Z8v8,49
|
|
12
|
+
sqlproof/contrib/supabase.py,sha256=KeSTJXOCW6BeQkvnY8J765ZOM0wQcVtyDZ6A4Nr4gXY,4344
|
|
13
|
+
sqlproof/coverage/__init__.py,sha256=Rdg4sB9kESdai9qXUJFzf4vl9Pl9EcwwwQoD09qgzIg,217
|
|
14
|
+
sqlproof/coverage/diversity.py,sha256=s15dkD5O7kJb-Vp0Chsnbd0lrrCwsEhoobrdbwtKHKc,331
|
|
15
|
+
sqlproof/coverage/plpgsql.py,sha256=0jUENbB-mwAKqbQE94oTPMFOZ0ujnXVIJ4Lgh5tq9ag,88
|
|
16
|
+
sqlproof/coverage/schema_shape.py,sha256=JTx2ZQL3SLgbs3aymV1DtCnna_8bfvZ8zn04Epnjbmg,236
|
|
17
|
+
sqlproof/generators/__init__.py,sha256=ak5kxCLHUn0V7dKnRUafPFBbhYpxRSQsi1wdkT1rUk0,595
|
|
18
|
+
sqlproof/generators/columns.py,sha256=Tk-3vlXL6-zNpEEc5t5JP_YkMwDNQHw6g_c5QIZZwKk,3189
|
|
19
|
+
sqlproof/generators/constraints.py,sha256=khuP9PTP25oTKsPN6gJKMk0m3rQ91jxHnopBnySrFF8,5635
|
|
20
|
+
sqlproof/generators/functions.py,sha256=XqvPJ97VuaRIu-ca4I1nm6dfUB3juWaY56FWR076Q1E,164
|
|
21
|
+
sqlproof/generators/graph.py,sha256=5WyX4MUt_zosLFUx6V0nkvtBVvGtXITGKAP8w7RrM9k,1549
|
|
22
|
+
sqlproof/generators/rows.py,sha256=CJgWlaV-nnt5l3twVj81ZrJ4yj7sptWdDVLcqCunwHk,5526
|
|
23
|
+
sqlproof/generators/sampling.py,sha256=D6xvP3txBy_4iPEblbSX_CYiMwxREjhFpBIGHaufpXY,400
|
|
24
|
+
sqlproof/generators/well_known.py,sha256=bYfcXD87-DUC0lxP64BmcA-0deribng55awp2b3pYuQ,2245
|
|
25
|
+
sqlproof/reporter/__init__.py,sha256=rKqWCGcE2XXz6DRLgO749jH7KKF6KmMIgACuijtcauE,131
|
|
26
|
+
sqlproof/reporter/console.py,sha256=_pP6oHHmeJikJqgHW_Hb8u-CS-rxAhkveZIgxJkC5rI,752
|
|
27
|
+
sqlproof/reporter/json_io.py,sha256=aSYNG8214flmspVJe-9D20S1x_6k3aCtO85OMXTnrkg,834
|
|
28
|
+
sqlproof/runners/__init__.py,sha256=q3c-lvqx9-GzqiJEGM-zKGTYCfyjqQY8DRK2Cre9ppY,616
|
|
29
|
+
sqlproof/runners/db.py,sha256=pzRTvBAwmeBIDyNQl5bzu2_IfGw05hyA18CuTqyY8Mc,1557
|
|
30
|
+
sqlproof/runners/migration.py,sha256=ryvlKcPG8CBfYbCcI3Y8A4ghi5sERFDY9MGjWWQea8o,1472
|
|
31
|
+
sqlproof/runners/overload.py,sha256=BSkkJUlGZzJBRJ3jrIOqS4jeygPBMvansSmHRdixlRE,1349
|
|
32
|
+
sqlproof/runners/property.py,sha256=REnMlKld5V8_c8oZTz2viVAbF0qSb4ODn6XTsXg7Ch8,3534
|
|
33
|
+
sqlproof/runners/rls.py,sha256=XnUFrMge_bVfun5K0ItvWi5URXpwLgUNG0H2VGK2pBE,1213
|
|
34
|
+
sqlproof/runners/stateful.py,sha256=9TqcCgbJGN0vf3FJql5aXkuW0z221D7LhYIb4MlQcBI,1103
|
|
35
|
+
sqlproof/schema/__init__.py,sha256=oNxJFeW6mq8ueeIV-S-J8I0NyDj7oeDCEXj-Y2kn-aI,533
|
|
36
|
+
sqlproof/schema/dependency_graph.py,sha256=EfEHJVLv0F2REZXkPi4FRW_C29QZGp_bTk3boaX9S6M,1377
|
|
37
|
+
sqlproof/schema/fingerprint.py,sha256=PKRCwnNXBz_E7-LeDrcm1TGmbAK9nuuL7xkLsripW6o,1111
|
|
38
|
+
sqlproof/schema/introspect.py,sha256=Iem32N_Ol_XM0yLjtFnJ6vE3D361V9RrQzLOjXoF4Hs,8968
|
|
39
|
+
sqlproof/schema/model.py,sha256=cEgdTdXpOjQqN9zU8XCf25p-cmxF_bvwLHaMVu6x9Sk,2738
|
|
40
|
+
sqlproof/schema/parse_sql.py,sha256=kvMZ5D3Y9z_qOTH9k197bmLwv80GLPHKkWt-dH-67JY,7695
|
|
41
|
+
sqlproof-0.1.0a1.dist-info/METADATA,sha256=Juw_FOQz4aOhGiNGyJcqM-412loodOMugl92zIEw5WI,8827
|
|
42
|
+
sqlproof-0.1.0a1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
43
|
+
sqlproof-0.1.0a1.dist-info/entry_points.txt,sha256=BLzK4-9qJ7cVkVwVE_eCQtBMTMSPmjxvQkFW8RAAli4,93
|
|
44
|
+
sqlproof-0.1.0a1.dist-info/RECORD,,
|