|
| 1 | +"""Form fields → DSN assembly. |
| 2 | +
|
| 3 | +The setup wizard collects credentials field-by-field so non-developers never |
| 4 | +see a DSN string. Each ``build_*`` here turns those fields into the canonical |
| 5 | +SQLAlchemy/D1 URL that :func:`build_explorer` already understands. Splitting |
| 6 | +this off keeps the wizard's UI layer (Discord modals) thin and lets us unit- |
| 7 | +test the assembly without a Discord runtime. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from dataclasses import dataclass |
| 13 | +from urllib.parse import quote_plus |
| 14 | + |
| 15 | + |
| 16 | +@dataclass |
| 17 | +class ConnectionSpec: |
| 18 | + """The wizard's output: a DSN + any out-of-band secrets the adapter needs.""" |
| 19 | + |
| 20 | + dsn: str |
| 21 | + extras: dict[str, str] |
| 22 | + |
| 23 | + |
| 24 | +# Supported DB types in the wizard. Order matters — surfaces in the dropdown. |
| 25 | +SUPPORTED_DB_TYPES: tuple[str, ...] = ( |
| 26 | + "postgresql", |
| 27 | + "mysql", |
| 28 | + "snowflake", |
| 29 | + "bigquery", |
| 30 | + "duckdb", |
| 31 | + "d1", |
| 32 | +) |
| 33 | + |
| 34 | + |
| 35 | +def _quote(s: str) -> str: |
| 36 | + return quote_plus(s, safe="") |
| 37 | + |
| 38 | + |
| 39 | +def build_postgresql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec: |
| 40 | + p = int(port) if port else 5432 |
| 41 | + dsn = f"postgresql+psycopg://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}" |
| 42 | + return ConnectionSpec(dsn=dsn, extras={}) |
| 43 | + |
| 44 | + |
| 45 | +def build_mysql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec: |
| 46 | + p = int(port) if port else 3306 |
| 47 | + dsn = f"mysql+pymysql://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}" |
| 48 | + return ConnectionSpec(dsn=dsn, extras={}) |
| 49 | + |
| 50 | + |
| 51 | +def build_snowflake( |
| 52 | + *, account: str, user: str, password: str, database: str, warehouse: str |
| 53 | +) -> ConnectionSpec: |
| 54 | + dsn = ( |
| 55 | + f"snowflake://{_quote(user)}:{_quote(password)}@{account}" |
| 56 | + f"/{database}?warehouse={_quote(warehouse)}" |
| 57 | + ) |
| 58 | + return ConnectionSpec(dsn=dsn, extras={}) |
| 59 | + |
| 60 | + |
| 61 | +def build_bigquery(*, project: str, dataset: str) -> ConnectionSpec: |
| 62 | + # Auth via Application Default Credentials (gcloud) — credentials are not |
| 63 | + # in the DSN. We document this in the wizard's success message. |
| 64 | + dsn = f"bigquery://{project}/{dataset}" |
| 65 | + return ConnectionSpec(dsn=dsn, extras={}) |
| 66 | + |
| 67 | + |
| 68 | +def build_duckdb(*, path: str) -> ConnectionSpec: |
| 69 | + return ConnectionSpec(dsn=f"duckdb:///{path}", extras={}) |
| 70 | + |
| 71 | + |
| 72 | +def build_d1(*, account_id: str, database_id: str, api_token: str) -> ConnectionSpec: |
| 73 | + # The token doesn't go in the URL — it's an out-of-band header. |
| 74 | + return ConnectionSpec( |
| 75 | + dsn=f"d1://{account_id}/{database_id}", |
| 76 | + extras={"d1_token": api_token}, |
| 77 | + ) |
| 78 | + |
| 79 | + |
| 80 | +# Field schemas surfaced by the Discord Modal layer. Each entry is |
| 81 | +# (label, placeholder, required, masked). |
| 82 | +FIELD_SCHEMA: dict[str, list[tuple[str, str, bool, bool]]] = { |
| 83 | + "postgresql": [ |
| 84 | + ("host", "db.example.com", True, False), |
| 85 | + ("port", "5432", False, False), |
| 86 | + ("database", "analytics", True, False), |
| 87 | + ("user", "readonly_user", True, False), |
| 88 | + ("password", "•••••", True, True), |
| 89 | + ], |
| 90 | + "mysql": [ |
| 91 | + ("host", "db.example.com", True, False), |
| 92 | + ("port", "3306", False, False), |
| 93 | + ("database", "analytics", True, False), |
| 94 | + ("user", "readonly_user", True, False), |
| 95 | + ("password", "•••••", True, True), |
| 96 | + ], |
| 97 | + "snowflake": [ |
| 98 | + ("account", "abc12345.us-east-1", True, False), |
| 99 | + ("user", "readonly_user", True, False), |
| 100 | + ("password", "•••••", True, True), |
| 101 | + ("database", "ANALYTICS", True, False), |
| 102 | + ("warehouse", "COMPUTE_WH", True, False), |
| 103 | + ], |
| 104 | + "bigquery": [ |
| 105 | + ("project", "my-gcp-project", True, False), |
| 106 | + ("dataset", "analytics", True, False), |
| 107 | + ], |
| 108 | + "duckdb": [ |
| 109 | + ("path", "/data/warehouse.duckdb", True, False), |
| 110 | + ], |
| 111 | + "d1": [ |
| 112 | + ("account_id", "Cloudflare account ID", True, False), |
| 113 | + ("database_id", "D1 database ID", True, False), |
| 114 | + ("api_token", "Cloudflare API token", True, True), |
| 115 | + ], |
| 116 | +} |
| 117 | + |
| 118 | + |
| 119 | +_BUILDERS = { |
| 120 | + "postgresql": build_postgresql, |
| 121 | + "mysql": build_mysql, |
| 122 | + "snowflake": build_snowflake, |
| 123 | + "bigquery": build_bigquery, |
| 124 | + "duckdb": build_duckdb, |
| 125 | + "d1": build_d1, |
| 126 | +} |
| 127 | + |
| 128 | + |
| 129 | +def assemble(db_type: str, fields: dict[str, str]) -> ConnectionSpec: |
| 130 | + """Dispatch by ``db_type`` to the matching builder. |
| 131 | +
|
| 132 | + The wizard hands raw modal inputs in ``fields``; this is the one entry |
| 133 | + point so the UI layer stays dialect-agnostic. |
| 134 | + """ |
| 135 | + builder = _BUILDERS.get(db_type) |
| 136 | + if builder is None: |
| 137 | + raise ValueError(f"unsupported db type: {db_type!r}") |
| 138 | + # Filter to the expected kwargs (modal can hand stray keys safely). |
| 139 | + expected = {name for name, *_ in FIELD_SCHEMA[db_type]} |
| 140 | + cleaned = {k: (v or "").strip() for k, v in fields.items() if k in expected} |
| 141 | + missing = [n for n, _, req, _ in FIELD_SCHEMA[db_type] if req and not cleaned.get(n)] |
| 142 | + if missing: |
| 143 | + raise ValueError(f"missing required fields: {', '.join(missing)}") |
| 144 | + return builder(**cleaned) |
0 commit comments