Compare commits

..

2 Commits

Author SHA1 Message Date
546242e11a Wizard step 2: schema browser panel with datalist autocomplete
Adds a /wizard/schemas JSON endpoint and a live-filtered schema picker
panel on step 2. Clicking a row fills the schema input; the datalist
also powers browser autocomplete. MSSQL refetches when database or
linked_server qualifiers change. CSS fixes prevent picker tables and
two-col grid items from overflowing their containers.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 20:56:18 -04:00
ff19ae9b81 Drivers: add list_schemas() to base, PG, DB2, MSSQL
Base provides a no-op default; drivers opt in by overriding. MSSQL
scopes the lookup to a linked server / database when those qualifiers
are supplied.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 20:56:10 -04:00
7 changed files with 171 additions and 0 deletions

View File

@ -98,6 +98,14 @@ class Driver(abc.ABC):
def list_tables(self, conn: dict, **qualifiers) -> list[RemoteTable]:
"""Fetch tables/views matching the qualifiers."""
def list_schemas(self, conn: dict, **qualifiers) -> list[str]:
"""Return schema/library names available on the source.
Default returns []; drivers opt in by overriding. ``qualifiers`` lets
drivers like MSSQL scope the lookup to a database/linked-server.
"""
return []
@abc.abstractmethod
def get_columns(self, conn: dict, table: str, **qualifiers) -> list[RemoteColumn]:
"""Fetch column metadata for one table."""

View File

@ -44,6 +44,12 @@ class DB2Driver(Driver):
help="e.g. RLDBF12"),
]
def list_schemas(self, conn, **_) -> list[str]:
result = self.query(
conn, "SELECT SCHEMA_NAME FROM QSYS2.SYSSCHEMAS "
"ORDER BY SCHEMA_NAME")
return [r[0].strip() for r in result.rows if r and r[0]]
def list_tables(self, conn, *, schema: str) -> list[RemoteTable]:
validate_identifier(schema, "schema")
sql = (

View File

@ -49,6 +49,20 @@ class MSSQLDriver(Driver):
required=False, default="dbo"),
]
def list_schemas(
self, conn, *, linked_server: str | None = None,
database: str | None = None, **_,
) -> list[str]:
self._validate(linked_server, database, None)
prefix = self._info_schema_prefix(linked_server, database)
sql = (
f"SELECT DISTINCT TABLE_SCHEMA FROM {prefix}INFORMATION_SCHEMA.TABLES "
f"WHERE TABLE_TYPE IN ('BASE TABLE','VIEW') "
f"ORDER BY TABLE_SCHEMA"
)
result = self.query(conn, sql)
return [r[0].strip() for r in result.rows if r and r[0]]
def list_tables(
self, conn, *, linked_server: str | None = None,
database: str | None = None, schema: str | None = None,

View File

@ -41,6 +41,15 @@ class PGDriver(Driver):
required=False, default="public"),
]
def list_schemas(self, conn, **_) -> list[str]:
result = self.query(
conn,
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name NOT IN ('pg_catalog','information_schema') "
"AND schema_name NOT LIKE 'pg\\_%' ESCAPE '\\' "
"ORDER BY schema_name")
return [r[0].strip() for r in result.rows if r and r[0]]
def list_tables(self, conn, *, schema: str | None = None) -> list[RemoteTable]:
if schema:
validate_identifier(schema, "schema")

View File

@ -295,6 +295,29 @@ def wizard_step2(request: Request,
)
@_router.get("/wizard/schemas")
def wizard_schemas(request: Request,
source_connection_id: int = Query(...)):
"""JSON list of schema names. Drivers that need scoping (e.g. MSSQL by
database) read those qualifiers from the querystring."""
conn = repo.get_connection(source_connection_id)
if conn is None:
raise HTTPException(404, f"connection id={source_connection_id} not found")
drv = _driver_for_conn(conn)
if drv is None:
raise HTTPException(500, "driver row missing for connection")
qp = dict(request.query_params)
qvals = {f.name: qp[f.name] for f in drv.browse_fields()
if f.name != "schema" and qp.get(f.name)}
try:
names = drv.list_schemas(conn, **qvals)
except (jrunner.JrunnerError, ValueError) as e:
return {"error": str(e), "schemas": []}
except Exception as e: # noqa: BLE001
return {"error": f"{type(e).__name__}: {e}", "schemas": []}
return {"schemas": names}
@_router.get("/wizard/columns", response_class=HTMLResponse)
def wizard_step3(request: Request,
source_connection_id: int = Query(...),

View File

@ -206,6 +206,9 @@ form.inline { display: inline; }
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
/* Grid items default to min-width:auto which is content-sized; let them
shrink so wide tables/inputs inside don't blow out the layout. */
.two-col > * { min-width: 0; }
@media (max-width: 900px) {
.two-col { grid-template-columns: 1fr; }
}
@ -261,11 +264,16 @@ label.field .help { grid-column: 2; color: var(--text-muted); font-size: 12px; }
.steps .step .num { font-weight: 700; margin-right: 0.4rem; }
/* Radio/checkbox-in-row tables */
table.picker { table-layout: fixed; }
table.picker td.pick { width: 2.5rem; text-align: center; }
table.picker input[type="radio"],
table.picker input[type="checkbox"] { margin: 0; }
table.picker tbody tr { cursor: pointer; }
table.picker tbody tr:hover td { background: #1c2128; }
/* Drop the global 14rem min-width for in-row text inputs so the picker
table can shrink to its container instead of pushing past it. */
table.picker input[type="text"] { min-width: 0; }
table.picker td { overflow: hidden; text-overflow: ellipsis; }
/* Flash messages */
.flash {

View File

@ -22,10 +22,12 @@
<input type="text" name="{{ f.name }}"
value="{{ qvals.get(f.name, '') }}"
{% if f.required %}required{% endif %}
{% if f.name == 'schema' %}list="schema-options" autocomplete="off" id="schema-input"{% endif %}
placeholder="{{ f.default or '' }}">
{% if f.help %}<span class="help">{{ f.help }}</span>{% endif %}
</label>
{% endfor %}
<datalist id="schema-options"></datalist>
<label class="field">
<span>table (skip browse)</span>
@ -64,11 +66,112 @@
}
});
updateBtn();
// Populate <datalist> + visible "Schemas" panel from the
// source DB. Refetch when scoping qualifiers change (mssql:
// database, linked_server).
var dlist = document.getElementById('schema-options');
var countTag = document.getElementById('schema-count');
var gridBody = document.getElementById('schema-grid-body');
var emptyTag = document.getElementById('schema-empty');
var filterInput = document.getElementById('schema-filter');
var dbInput = document.querySelector('input[name="database"]');
var lsInput = document.querySelector('input[name="linked_server"]');
var connId = {{ connection.id }};
var allSchemas = [];
var inFlight = 0;
function applySchemaFilter() {
var q = (filterInput.value || '').toLowerCase().trim();
var visible = 0;
Array.prototype.forEach.call(gridBody.children, function (tr) {
var n = tr.getAttribute('data-name') || '';
var show = !q || n.toLowerCase().indexOf(q) !== -1;
tr.style.display = show ? '' : 'none';
if (show) visible++;
});
countTag.textContent = q
? visible + ' of ' + allSchemas.length + ' shown'
: allSchemas.length + ' total';
}
function renderSchemas() {
dlist.innerHTML = '';
gridBody.innerHTML = '';
allSchemas.forEach(function (s) {
var o = document.createElement('option');
o.value = s;
dlist.appendChild(o);
var tr = document.createElement('tr');
tr.setAttribute('data-name', s);
tr.style.cursor = 'pointer';
var td = document.createElement('td');
td.className = 'mono';
td.textContent = s;
tr.appendChild(td);
tr.addEventListener('click', function () {
if (schemaInput) schemaInput.value = s;
});
gridBody.appendChild(tr);
});
emptyTag.style.display = allSchemas.length ? 'none' : '';
applySchemaFilter();
}
function loadSchemas() {
var qs = new URLSearchParams({ source_connection_id: connId });
if (dbInput && dbInput.value.trim()) qs.set('database', dbInput.value.trim());
if (lsInput && lsInput.value.trim()) qs.set('linked_server', lsInput.value.trim());
var token = ++inFlight;
countTag.textContent = 'loading…';
fetch('/wizard/schemas?' + qs.toString())
.then(function (r) { return r.json(); })
.then(function (j) {
if (token !== inFlight) return; // stale
allSchemas = j.schemas || [];
renderSchemas();
if (j.error) {
countTag.textContent = 'load failed: ' + j.error;
countTag.style.color = 'var(--danger)';
} else {
countTag.style.color = '';
}
})
.catch(function (e) {
if (token !== inFlight) return;
countTag.textContent = 'load failed: ' + e;
countTag.style.color = 'var(--danger)';
});
}
filterInput.addEventListener('input', applySchemaFilter);
if (dbInput) dbInput.addEventListener('change', loadSchemas);
if (lsInput) lsInput.addEventListener('change', loadSchemas);
loadSchemas();
})();
</script>
</div>
</div>
<div class="panel" id="schema-panel">
<header>
Schemas
<span class="subtitle" id="schema-count">loading…</span>
<span style="margin-left:auto">
<input type="text" id="schema-filter"
placeholder="filter (substring)"
autocomplete="off" spellcheck="false"
style="min-width:18rem;font-family:var(--mono);font-size:12px">
</span>
</header>
<div class="body tight" style="max-height:18rem;overflow:auto">
<table class="grid picker" id="schema-grid">
<tbody id="schema-grid-body"></tbody>
</table>
<div id="schema-empty" class="empty" style="display:none">no schemas</div>
</div>
</div>
{% if fetch_error %}
<div class="panel">
<header>Browse failed</header>