Skip to content

Commit a26d4b6

Browse files
committed
Bind capabilities to active SQL limits
1 parent 01d5ad3 commit a26d4b6

11 files changed

Lines changed: 450 additions & 28 deletions

File tree

‎src/openstatspec/api.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
from pathlib import Path
55
from typing import Any
66

7-
from .core import CapabilityDeclaration
87
from .core.results import result
98
from .spss import export_dataset, import_dataset, inspect_source
109
from .spss.sav import engine_identity
1110
from .sql import declared_profiles, validate_dataset
11+
from .sql.capabilities import (
12+
SPECIFICATION_COMMIT, SPECIFICATION_RELEASE, active_connection, catalog_binding,
13+
)
1214

1315

1416

15-
def capability_matrix() -> Mapping[str, Any]:
17+
def capability_matrix(database_url: str | None = None) -> Mapping[str, Any]:
1618
"""Return the pyspssio-backed SAV/ZSAV fidelity boundary.
1719
1820
supported means that the adapter has a tested faithful path.
@@ -21,7 +23,13 @@ def capability_matrix() -> Mapping[str, Any]:
2123
blocks export unless the documented audited loss route exists; a plain
2224
fail-closed feature has no faithful writer route at all.
2325
"""
24-
return {
26+
declaration = {
27+
"specification": "OpenStatSpec",
28+
"specification_status": "release_candidate",
29+
"specification_release": SPECIFICATION_RELEASE,
30+
"specification_commit": SPECIFICATION_COMMIT,
31+
"profile": "SPSS SAV/ZSAV 1.0",
32+
"directions": ["import", "export", "semantic_round_trip"],
2533
"required_capabilities": [
2634
"sav_read", "sav_write", "zsav_read", "zsav_write",
2735
"file_label", "documents", "source_encoding", "attributes",
@@ -53,15 +61,20 @@ def capability_matrix() -> Mapping[str, Any]:
5361
},
5462
"variable_role": "supported",
5563
},
56-
"sql_profiles": declared_profiles(),
64+
"resource_behavior": {
65+
"streaming_import": False, "streaming_export": False,
66+
"buffering": "fully_buffered", "maximum_cases": None,
67+
"maximum_source_file_bytes": None,
68+
"limit_basis": "runtime memory and active SQL connection limits",
69+
},
70+
"active_connection": active_connection(database_url) if database_url else None,
71+
"catalog_binding": catalog_binding(database_url) if database_url else None,
72+
"sql_profiles": declared_profiles(database_url),
5773
}
74+
return declaration
5875

59-
def capabilities() -> Mapping[str, Any]:
60-
return CapabilityDeclaration(
61-
specification="OpenStatSpec strict wide-table SPSS profile (initial)",
62-
formats={"SAV": {"import": True, "export": True}, "ZSAV": {"import": True, "export": True}},
63-
database_profiles=declared_profiles(),
64-
).as_dict()
76+
def capabilities(database_url: str | None = None) -> Mapping[str, Any]:
77+
return capability_matrix(database_url)
6578

6679

6780
def inspect(source: str | Path, /, **options: Any) -> Mapping[str, Any]:

‎src/openstatspec/cli.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
def main(argv: Sequence[str] | None = None) -> int:
1111
parser = argparse.ArgumentParser(prog="openstatspec")
1212
commands = parser.add_subparsers(dest="command", required=True)
13-
commands.add_parser("capabilities", help="show supported and lossy feature matrix")
13+
capability_parser = commands.add_parser("capabilities", help="show supported and lossy feature matrix")
14+
capability_parser.add_argument("--database-url", help="include active connection limits")
1415
importer = commands.add_parser("import", help="import one SAV/ZSAV file")
1516
importer.add_argument("source")
1617
importer.add_argument("--database-url", required=True)
@@ -28,7 +29,7 @@ def main(argv: Sequence[str] | None = None) -> int:
2829
exporter.add_argument("--legacy-locale", help="OS locale for a non-UTF-8 source encoding")
2930
args = parser.parse_args(argv)
3031
if args.command == "capabilities":
31-
result = capability_matrix()
32+
result = capability_matrix(database_url=args.database_url)
3233
elif args.command == "import":
3334
result = import_sav(args.source, database_url=args.database_url, dataset_id=args.dataset_id)
3435
elif args.command == "export":

‎src/openstatspec/sql/__init__.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from typing import Any
44

5-
from .profiles import PROFILES
5+
from .capabilities import profile_declarations
66
from .wide import validate_wide_dataset
77

88

9-
def declared_profiles() -> dict[str, dict[str, object]]:
10-
return {profile.name: profile.as_dict() for profile in PROFILES}
9+
def declared_profiles(database_url: str | None = None) -> dict[str, dict[str, object]]:
10+
return profile_declarations(database_url)
1111

1212

1313
def validate_dataset(*, database_url: Any, dataset_id: str, **options: Any) -> dict[str, Any]:
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""Machine-readable SQL and specification capability declarations."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from dataclasses import replace
7+
from pathlib import Path
8+
from typing import Any
9+
10+
from sqlalchemy import MetaData, create_engine, text
11+
from sqlalchemy.engine import make_url
12+
13+
from .normative import catalog
14+
from .profiles import MYSQL, POSTGRESQL, SQLITE, SqlProfile
15+
from .profiles import profile_for_url
16+
from ..core import UnsupportedOperationError
17+
18+
SPECIFICATION_COMMIT = "6b9d1fc38f2f083c0ac5cf1c64874a6d07b95045"
19+
SPECIFICATION_RELEASE: str | None = None
20+
21+
SERVER_POLICIES = {
22+
"sqlite": {
23+
"claimed": ["SQLite >=3.24.0 <4.0.0"],
24+
"ci": ["active GitHub runner SQLite version"],
25+
},
26+
"mysql": {
27+
"claimed": ["MySQL 8.4.x", "MySQL 9.7.x"],
28+
"ci": ["MySQL 8.4.x", "MySQL 9.7.x"],
29+
},
30+
"mariadb": {
31+
"claimed": ["MariaDB 11.4.x", "MariaDB 11.8.x", "MariaDB 12.3.x"],
32+
"ci": ["MariaDB 11.4.x", "MariaDB 11.8.x", "MariaDB 12.3.x"],
33+
},
34+
"postgresql": {
35+
"claimed": ["PostgreSQL 17.x", "PostgreSQL 18.x"],
36+
"ci": ["PostgreSQL 17.x", "PostgreSQL 18.x"],
37+
},
38+
}
39+
40+
41+
def profile_declarations(database_url: str | None = None) -> dict[str, dict[str, Any]]:
42+
"""Declare every profile and, optionally, one active connection."""
43+
active = active_connection(database_url) if database_url else None
44+
return {
45+
"sqlite": _profile("sqlite", SQLITE, active),
46+
"mysql": _profile("mysql", MYSQL, active),
47+
"mariadb": _profile("mariadb", MYSQL, active),
48+
"postgresql": _profile("postgresql", POSTGRESQL, active),
49+
}
50+
51+
52+
def active_connection(database_url: str) -> dict[str, Any]:
53+
engine = create_engine(database_url)
54+
configured = make_url(database_url)
55+
with engine.connect() as connection:
56+
dialect = connection.dialect.name
57+
if dialect == "sqlite":
58+
profile_name = "sqlite"
59+
raw_version = str(connection.execute(text("select sqlite_version()")).scalar_one())
60+
identity_source = "select sqlite_version()"
61+
compile_options = {
62+
name: int(value)
63+
for option in connection.exec_driver_sql("pragma compile_options").scalars()
64+
if "=" in str(option)
65+
for name, value in [str(option).split("=", 1)]
66+
if value.isdigit()
67+
}
68+
observed = {"compile_options": compile_options}
69+
elif dialect == "postgresql":
70+
profile_name = "postgresql"
71+
raw_version = str(connection.execute(text("show server_version")).scalar_one())
72+
identity_source = "SHOW server_version"
73+
observed = {}
74+
elif dialect in {"mysql", "mariadb"}:
75+
raw_version = str(connection.execute(text("select @@version")).scalar_one())
76+
comment = str(connection.execute(text("select @@version_comment")).scalar_one())
77+
profile_name = "mariadb" if "mariadb" in (raw_version + " " + comment).lower() else "mysql"
78+
identity_source = "SELECT @@version, @@version_comment"
79+
observed = {
80+
"max_allowed_packet": int(
81+
connection.execute(text("select @@max_allowed_packet")).scalar_one()
82+
)
83+
}
84+
else: # pragma: no cover - validate_connection_url rejects this first
85+
raise ValueError(f"Unsupported active SQL dialect {dialect!r}.")
86+
return {
87+
"profile": profile_name,
88+
"server_version": _normalized_version(raw_version),
89+
"raw_server_version": raw_version,
90+
"identity_source": identity_source,
91+
"claimed_supported": server_version_supported(profile_name, raw_version),
92+
"matched_claim": _matched_claim(profile_name, raw_version),
93+
"catalog_binding": catalog_binding(database_url),
94+
"observed": observed,
95+
}
96+
97+
98+
def effective_profile(database_url: str) -> tuple[SqlProfile, dict[str, Any]]:
99+
"""Resolve and enforce the profile used by import preflight."""
100+
configured = profile_for_url(database_url)
101+
active = active_connection(database_url)
102+
if configured is not MYSQL and active["profile"] != configured.name:
103+
raise UnsupportedOperationError("The active SQL server does not match the configured profile.")
104+
if configured is MYSQL and active["profile"] not in {"mysql", "mariadb"}:
105+
raise UnsupportedOperationError("The active SQL server is not MySQL or MariaDB.")
106+
if not active["claimed_supported"]:
107+
raise UnsupportedOperationError(
108+
f"Active {active['profile']} server version {active['server_version']} is not claimed supported."
109+
)
110+
declaration = _profile(active["profile"], configured, active)
111+
limits = declaration["effective_limits"]
112+
assert limits is not None
113+
return replace(
114+
configured,
115+
name=active["profile"],
116+
max_physical_variables=int(limits["maximum_source_variables"]),
117+
max_text_value_bytes=int(limits["maximum_value_bytes"]),
118+
max_row_bytes=int(limits["maximum_row_bytes"]),
119+
), active
120+
121+
122+
def catalog_binding(database_url: str) -> dict[str, Any]:
123+
url = make_url(database_url)
124+
database = url.database or ""
125+
if url.get_backend_name() == "sqlite":
126+
namespace = str(Path(database).resolve()) if database and database != ":memory:" else database
127+
mode = "dedicated_database_file"
128+
else:
129+
namespace = database
130+
mode = "dedicated_database"
131+
logical = catalog(MetaData())
132+
return {
133+
"mode": mode,
134+
"namespace": namespace,
135+
"identity_marker": "catalog_identity",
136+
"contract_id": "openstatspec-strict-wide-table-v1",
137+
"schema_version": 1,
138+
"required_isolation": "exclusive OpenStatSpec catalog connection",
139+
"logical_to_physical_relations": {
140+
table.name: table.name for table in logical.all()
141+
},
142+
}
143+
144+
145+
def _profile(
146+
name: str, profile: SqlProfile, active: dict[str, Any] | None,
147+
) -> dict[str, Any]:
148+
identifier = {
149+
"value": profile.identifier_limit,
150+
"unit": "characters" if name in {"mysql", "mariadb"} else "bytes",
151+
"source": (
152+
"MySQL/MariaDB native identifier limit" if name in {"mysql", "mariadb"}
153+
else "PostgreSQL NAMEDATALEN minus one native byte limit" if name == "postgresql"
154+
else "OpenStatSpec profile boundary; SQLite has no fixed native identifier limit"
155+
),
156+
"repertoire": "generated ASCII [a-z0-9_] identifiers",
157+
}
158+
theoretical = {
159+
"maximum_physical_columns": profile.max_physical_variables + 1,
160+
"maximum_source_variables": profile.max_physical_variables,
161+
"identifier_limit": identifier,
162+
"maximum_value_bytes": profile.max_text_value_bytes,
163+
"maximum_row_bytes": profile.max_row_bytes,
164+
}
165+
effective = None
166+
status = "not_connected"
167+
if active and active["profile"] == name:
168+
effective = dict(theoretical)
169+
sources = {
170+
"maximum_source_variables": "profile theoretical engine ceiling",
171+
"maximum_physical_columns": "profile theoretical engine ceiling",
172+
"identifier_limit": identifier["source"],
173+
"maximum_value_bytes": "profile theoretical engine ceiling",
174+
"maximum_row_bytes": "profile theoretical engine ceiling",
175+
}
176+
observed = active["observed"]
177+
if name == "sqlite":
178+
options = observed["compile_options"]
179+
if "MAX_COLUMN" in options:
180+
effective["maximum_physical_columns"] = min(
181+
theoretical["maximum_physical_columns"], options["MAX_COLUMN"],
182+
)
183+
effective["maximum_source_variables"] = max(
184+
0, effective["maximum_physical_columns"] - 1,
185+
)
186+
sources["maximum_physical_columns"] = "active PRAGMA compile_options MAX_COLUMN"
187+
sources["maximum_source_variables"] = "active MAX_COLUMN minus technical ordinal"
188+
if "MAX_LENGTH" in options:
189+
effective["maximum_value_bytes"] = min(
190+
theoretical["maximum_value_bytes"], options["MAX_LENGTH"],
191+
)
192+
effective["maximum_row_bytes"] = min(
193+
theoretical["maximum_row_bytes"], options["MAX_LENGTH"],
194+
)
195+
sources["maximum_value_bytes"] = "active PRAGMA compile_options MAX_LENGTH"
196+
sources["maximum_row_bytes"] = "active PRAGMA compile_options MAX_LENGTH"
197+
status = "active_connection_mixed"
198+
elif name in {"mysql", "mariadb"}:
199+
packet = int(observed["max_allowed_packet"])
200+
payload = max(0, (packet - 131_072) // 2)
201+
effective["maximum_value_bytes"] = min(
202+
theoretical["maximum_value_bytes"], payload,
203+
)
204+
effective["maximum_statement_bytes"] = payload
205+
sources["maximum_value_bytes"] = "active @@max_allowed_packet worst-case payload"
206+
sources["maximum_statement_bytes"] = "active @@max_allowed_packet worst-case payload"
207+
status = "active_connection_mixed"
208+
else:
209+
status = "profile_theoretical_fallback"
210+
effective["sources"] = sources
211+
policy = SERVER_POLICIES[name]
212+
return {
213+
"driver": "psycopg" if name == "postgresql" else "PyMySQL" if name in {"mysql", "mariadb"} else "sqlite3",
214+
"claimed_server_versions": policy["claimed"],
215+
"ci_tested_server_versions": policy["ci"],
216+
"theoretical_limits": theoretical,
217+
"effective_limits": effective,
218+
"effective_limits_status": status,
219+
"numeric_type": "DOUBLE PRECISION" if name == "postgresql" else "DOUBLE" if name in {"mysql", "mariadb"} else "REAL",
220+
"text_type": "LONGTEXT" if name in {"mysql", "mariadb"} else "TEXT",
221+
"ddl_atomic": name not in {"mysql", "mariadb"},
222+
"failure_cleanup": "compensating_cleanup" if name in {"mysql", "mariadb"} else "transaction_rollback",
223+
"physical_table_mapping": "dataset.physical_table_schema + dataset.physical_table_name",
224+
"identifier_policy": "deterministic ASCII mapping; source name remains authoritative",
225+
}
226+
227+
228+
def server_version_supported(profile: str, raw_version: str) -> bool:
229+
version = _version_tuple(raw_version)
230+
if profile == "sqlite":
231+
return (3, 24) <= version[:2] < (4, 0)
232+
allowed = {
233+
"postgresql": {(17,), (18,)},
234+
"mysql": {(8, 4), (9, 7)},
235+
"mariadb": {(11, 4), (11, 8), (12, 3)},
236+
}[profile]
237+
width = len(next(iter(allowed)))
238+
return version[:width] in allowed
239+
240+
241+
def _matched_claim(profile: str, raw_version: str) -> str | None:
242+
if not server_version_supported(profile, raw_version):
243+
return None
244+
version = _version_tuple(raw_version)
245+
for claim in SERVER_POLICIES[profile]["claimed"]:
246+
numbers = _version_tuple(claim)
247+
if profile == "sqlite" or version[:2] == numbers[:2] or version[:1] == numbers[:1]:
248+
return claim
249+
return SERVER_POLICIES[profile]["claimed"][0]
250+
251+
252+
def _version_tuple(raw_version: str) -> tuple[int, ...]:
253+
match = re.search(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", raw_version)
254+
if not match:
255+
return ()
256+
return tuple(int(part) for part in match.groups(default="0"))
257+
258+
259+
def _normalized_version(raw_version: str) -> str:
260+
return ".".join(str(part) for part in _version_tuple(raw_version))

0 commit comments

Comments
 (0)