|
| 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