Skip to content

feat(explorer): inspect a column's relationships and usage evidence - #1479

Merged
debba merged 6 commits into
TabularisDB:mainfrom
shu-chen-dot-ca:feat-1407-column-usage
Nov 13, 2026
Merged

debba merged 6 commits into
TabularisDB:mainfrom
shu-chen-dot-ca:feat-1407-column-usage

Conversation

@shu-chen-dot-ca

@shu-chen-dot-ca shu-chen-dot-ca commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1407.

Adds a Show Usage action to a column's context menu in the explorer sidebar. It opens a panel that collects, for that one column, the declared foreign keys, references in view definitions and matches in this connection's Tabularis query history. Every item shows its source, an excerpt and a confidence level, instead of implying complete lineage. The boundary (database metadata plus local history) is stated in the panel.

What changed

  • utils/columnUsage.ts (pure): a dialect-aware reference finder built on the existing sqlSplitter tokenizer (optionsFor(dialect), plus plugin sql_syntax through resolveSqlSyntax), so quoting, comments, dollar quotes and MySQL /*! */ follow the connection's dialect.
    • Each SELECT/WITH parenthesis is its own scope. Aliases, CTEs, derived tables and table functions are bindings there, and unqualified columns also resolve against enclosing scopes (correlated subqueries).
    • Names compare by the dialect's identifier rules (identifierRulesFor):
      • PostgreSQL and Oracle fold unquoted names (to lower and upper case) and keep quoted ones exact.
      • MySQL/MariaDB compare database and table names exactly and column names without case.
      • SQLite compares names without case for ASCII letters only, so "Ä" and "ä" stay distinct.
      • SQL Server, and dialects without known rules, compare catalog names exactly, since the collation isn't known.
    • parsed: the column resolves to the target schema.table, either qualified or through an alias.
    • uncertain, with a reason: unresolved qualifier, table without a schema, several tables in scope, derived table, alias conflict, *, dynamic SQL (EXECUTE, PREPARE, sp_executesql), a name that differs only by case where that depends on server settings (MySQL table names with lower_case_table_names, SQL Server and unknown dialects with the collation), or a textual match only.
    • No match: the same column name on another table or another schema, a name that differs by case where the dialect keeps case, a CTE that shadows the table name, or text inside strings and comments. Same-name columns alone are never reported as confirmed.
  • Redaction: excerpts and the copied summary replace every string and number literal with '?' / ? (including X'', 0x.., b'', N'', E'', dollar-quoted bodies and MySQL double-quoted strings). --, # and /* */ comments are dropped from excerpts and the summary, since they often carry ids, emails or notes. The summary never includes raw SQL.
  • utils/columnUsageCollector.ts: runs the lookups (get_foreign_keys, get_schema_snapshot, get_views / get_view_definition, get_query_history) one after the other. All of these already exist on both desktop and web transports, so there are no backend changes.
    • Collection is bounded (200 views, 2000 history entries, 200 KB of SQL per item) and cancellable through an AbortSignal. The history scan yields to the event loop, so Stop works during it.
    • Each source reports complete, partial (with the reason, e.g. limit reached or some definitions unreadable), unsupported, permission-denied, failed or cancelled. An unexpected error marks whatever was still pending as failed.
    • "No evidence found" is only shown when every source completed. Otherwise the panel says the result is incomplete.
    • Foreign keys are matched by referenced schema (getReferencedSchema: ref_schema from fix(postgres): resolve cross-schema foreign key references #1474, else the source schema), table and column, in both directions, using the same identifier rules. Incoming keys come from the target schema's snapshot, and outgoing keys open in the schema they reference.
  • ColumnUsageModal: shows the scope (connection / database / schema / table / column), the per-source status and the Confirmed / Parsed / Uncertain groups with their reasons. Open goes to the related table or view through useDatabaseObjectNavigation. Open in editor loads a history entry into a console without running it. Stop, Search again and Copy summary are also there. It is built on the existing Modal (Escape), puts initial focus on Close, uses design tokens and has visible focus rings.
  • SidebarColumnItem: the context menu now opens for every table column. Modify and Delete are still shown only where the column can be managed.
  • New strings in all 13 locales.

Validation

  • 69 new tests in tests/utils/columnUsage.test.ts and tests/utils/columnUsageCollector.test.ts. They cover:
    • the same table and column names across schemas, and same-name columns in other tables;
    • foreign keys into a same-named table in another schema, in both directions, and keys without a referenced schema;
    • identifier case per dialect: quoted PostgreSQL names ("PUBLIC", "Orders", "CUSTOMER_ID"), Oracle folding, MySQL table names in another case, SQLite "Ä" / "ä" (with the REFERENCES ORDERS(ID) spelling SQLite reports), and SQL Server dbo.Orders / dbo.orders with unknown collation;
    • alias, CTE, derived-table and correlated-subquery resolution;
    • wildcard and dynamic SQL;
    • Postgres, MySQL and SQL Server quoting;
    • literal and comment redaction in excerpts and in the summary;
    • permission-denied, unsupported, failed, partial and limit states;
    • cancellation midway, including during the history scan;
    • confidence classification and ordering.
  • Official frontend checks: pnpm run typecheck && pnpm run lint && pnpm run web:boundaries && pnpm run web:manual-parity && pnpm exec vitest --config packages/web-ui/vitest.config.ts run --maxWorkers=2. All passed: 561 files and 9009 tests.
    • Two earlier runs with 4 workers on a heavily loaded machine passed every test but reported one unhandled error. It came from ExplainGraph's 50 ms fitView timer firing after jsdom teardown in VisualExplainView.test.tsx, which is unrelated to this change; that file passes on its own.
  • Manual check with Tabularis Web (this branch's dist on the nightly binary of the first version's base commit) against PostgreSQL 16, with public.orders and sales.orders, a composite FK and three views:
    • public.orders.customer_id shows 2 confirmed FKs, 1 parsed view, 3 parsed and 1 uncertain history queries;
    • the sales.orders queries and unrelated views are excluded;
    • Open and Open in editor work, and the latter does not run the query;
    • the copied summary has no literals.
  • GitNexus: impact SidebarColumnItem (upstream) gives LOW risk, with 2 direct callers and 1 flow, all in ExplorerSidebar. impact of collectColumnUsage and findColumnReferences (upstream) gives LOW risk. Everything else is new files and locale entries.

Screenshots

Before After
before after
Foreign keys Views and history
after after

while (['LATERAL', 'ONLY', 'TABLE'].includes(keyword(tokens[i]))) i++;
let binding: Binding;
let defaultAlias: string | undefined;
if (tokens[i]?.value === '(') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: First table inside a parenthesised join is never registered

When FROM is followed by (, readRelation treats it as a derived table: it pushes a ref-less relation and returns without reading anything inside the parens. The main loop then only picks up tables after a JOIN/, keyword inside, so the leading table is dropped.

MySQL's SHOW CREATE VIEW writes joins exactly this way, e.g. (dialect mysql, target orders.customer_id):

select `o`.`customer_id` from (`orders` `o` join `customers` `c` on((`c`.`id` = `o`.`customer_id`)))

orders o is never bound, so o.customer_id is silently dropped (false negative) on the main view-definition use case. With 3+ tables MySQL nests ((a join b) join c), so the first table of every nest is lost. Tests only cover single-table MySQL views.

// character at a time.
if (
WORD_START.test(ch) ||
(DIGIT.test(ch) && !WORD_CHAR.test(source[position - 1] ?? ''))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Numeric literal after $ is not redacted (privacy leak)

WORD_CHAR includes $, so a digit preceded by $ skips the number branch and each digit is pushed as raw punct (lines 176-181). In dialects without dollar quoting this survives redaction. E.g. dialect mssql: SELECT customer_id FROM orders WHERE total > $100.50 yields a snippet/summary still containing $100.50 (MSSQL money literal); the same happens for $4111111111111111 in MySQL/SQLite/generic. The PR promises that every number literal is replaced with ?.

const prev = tokens[i - 1];
const bare =
keyword(prev) === 'SELECT' || keyword(prev) === 'DISTINCT' || prev?.value === ',';
if (bare && targetInStatement) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Wildcard check ignores which query level the * belongs to

targetInStatement is statement-wide, so a * in an unrelated scope is reported as uncertain:wildcard for the target. E.g. SELECT * FROM customers WHERE id IN (SELECT id FROM orders) or SELECT * FROM customers WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cid = customers.id) flags orders.customer_id, although the * only expands customers. SELECT * inside an EXISTS (...) on the target never reads the column either. Please resolve the * against the tables of its own scope (as done for unqualified columns).

Also, line 606-607 only accepts SELECT/DISTINCT/, before *, so SELECT TOP 10 * FROM orders, SELECT ALL * and SELECT SQL_CALC_FOUND_ROWS * are missed.

}
const word = keyword(token);
if (!word) continue;
if (LIST_END.has(word)) listDepths.delete(depth);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Comma join after an ON clause is not read as a relation

ON/USING are in LIST_END, which deletes listDepths for the current depth, so in SELECT customer_id FROM orders o JOIN x ON x.a = o.a, customers c the relation customers c is never registered. The unqualified customer_id is then reported parsed instead of uncertain:several-tables (a false parsed, which the PR says should not happen for ambiguous references).

if (word === 'FROM' || word === 'UPDATE') listDepths.add(depth);
const next = readRelation(i + 1, word === 'FROM' || word === 'JOIN');
if (next > i + 1) i = next - 1;
} else if (word === 'USING' && tokens[i + 1]?.value !== '(') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: USING without ( is always read as a relation

PL/pgSQL EXECUTE format(...) USING some_var registers some_var as a table, which makes model.relations.length non-zero and suppresses the textual fallback. The existing test only passes because USING 4 is a number token. Consider restricting this branch to statements that started with DELETE.

// Table function: its columns are not the table's.
i = skipParens(tokens, i) + 1;
binding = {scope: scopes[start]};
} else if (!schema && cteNames.has(name.toLowerCase())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: CTE names are statement-global, so a CTE that reads the same-named table hides the real table

WITH orders AS (SELECT * FROM orders WHERE ...) SELECT customer_id FROM orders: the inner unqualified orders matches cteNames and is bound without a ref, so the real table reference is missed. Schema-qualified inner references are fine.

@kilo-code-bot

kilo-code-bot Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

The incremental changes (3b025ecd..e125d8de) extend dialect identifier handling. I found no problems.

  • SQLite: names compare case-insensitively for ASCII letters only.
  • SQL Server and unknown dialects: names compare exactly, and a name that differs only in case is reported as name-case.
  • Collector: it uses the shared nameKey helper.
  • Locales: the name-case message was updated in all 13 locale files.

I read the diffs of columnUsage.ts, columnUsageCollector.ts and en.json in full. I did not read the other locale files or the test files.

Files Reviewed (3 files)
  • packages/web-ui/src/utils/columnUsage.ts
  • packages/web-ui/src/utils/columnUsageCollector.ts
  • packages/web-ui/src/i18n/locales/en.json
Previous Review Summaries (5 snapshots, latest commit 3b025ec)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 3b025ec)

Status: No Issues Found | Recommendation: Merge

The incremental changes (7ad25cd9..3b025ecd) make column-usage matching follow each dialect's identifier rules. I found no problems.

  • Case rules: Postgres and Oracle fold unquoted names. MySQL table names are treated as case-uncertain and reported as name-case. SQL Server and SQLite compare without case.
  • Collector: it now uses the same rules for table, column and schema comparisons.

I read the diffs of columnUsage.ts and columnUsageCollector.ts in full. I did not read the test files or the other files in the incremental diff.

Files Reviewed (2 files)
  • packages/web-ui/src/utils/columnUsage.ts
  • packages/web-ui/src/utils/columnUsageCollector.ts

Previous review (commit 7ad25cd)

Status: No Issues Found | Recommendation: Merge

The incremental changes (cd88ffc3..7ad25cd9) make column-usage foreign-key matching schema-aware, using getReferencedSchema. I found no problems.

  • Outgoing keys: evidence IDs now include the referenced schema, and the evidence records objectSchema.
  • Incoming keys: keys that reference a different schema are skipped, so cross-schema keys no longer produce false matches.

The rest of the diff is merged main content, which I did not review.

Files Reviewed (1 file)
  • packages/web-ui/src/utils/columnUsageCollector.ts

Previous review (commit cd88ffc)

Status: No Issues Found | Recommendation: Merge

The incremental changes (7c4323a7..cd88ffc3) add three things. I found no problems in any of them.

  • The history scan now yields to the event loop every 100 entries or every 16 ms, and checks for cancellation on every entry. A Stop click and rendering can now run during a long scan.
  • failPendingSources marks any source still pending as failed, and the modal calls it in a .catch. The panel can no longer stay on "searching" after an unexpected throw.
  • The Close button in the modal now has autoFocus.

New tests cover the collector changes.

Files Reviewed (4 files)
  • packages/web-ui/src/components/modals/ColumnUsageModal.tsx
  • packages/web-ui/src/utils/columnUsageCollector.ts
  • packages/web-ui/tests/utils/columnUsage.test.ts
  • packages/web-ui/tests/utils/columnUsageCollector.test.ts

Previous review (commit 7c4323a)

Status: No Issues Found | Recommendation: Merge

The incremental changes (e6c01166..7c4323a7) fix every finding from the previous review. I re-checked each against the current code and none remain:

  • Parenthesised joins after FROM (MySQL SHOW CREATE VIEW form) are now read through joinGroups, including nested ((a join b) join c).
  • A digit after $ is lexed as a number, so $100.50 and $4111… are redacted in every dialect.
  • The * wildcard is resolved against the tables of its own SELECT. It handles TOP, ALL, DISTINCT ON and the MySQL modifiers, stops at UNION/INTERSECT/EXCEPT, and ignores EXISTS (...) scopes.
  • ON and USING no longer end the relation list, so a comma join after an ON clause is registered. ON DUPLICATE KEY and ON CONFLICT still end it.
  • USING is read as a relation only after DELETE or MERGE.
  • CTE names are scoped to the query after their definition, or inside their own body when recursive.

New tests in columnUsage.test.ts cover each of these cases.

Files Reviewed (2 files in the incremental diff)
  • packages/web-ui/src/utils/columnUsage.ts
  • packages/web-ui/tests/utils/columnUsage.test.ts

Previous review (commit e6c0116)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
packages/web-ui/src/utils/columnUsage.ts 457 A parenthesised join after FROM (the form MySQL's SHOW CREATE VIEW emits) is treated as a derived table, so its first table/alias is never registered and references through it are silently dropped.
packages/web-ui/src/utils/columnUsage.ts 94 A digit after $ is not lexed as a number ($ is in WORD_CHAR), so literals such as MSSQL $100.50 survive redaction in excerpts and the copied summary.
packages/web-ui/src/utils/columnUsage.ts 608 The * wildcard check uses statement-wide targetInStatement, so SELECT * FROM customers WHERE id IN (SELECT id FROM orders) is flagged for orders.customer_id. TOP/ALL/SQL_CALC_FOUND_ROWS before * are missed.

SUGGESTION

File Line Issue
packages/web-ui/src/utils/columnUsage.ts 511 ON/USING end the relation list, so a comma join after an ON clause is not registered and an ambiguous column is reported parsed.
packages/web-ui/src/utils/columnUsage.ts 522 USING without ( is always read as a relation (e.g. PL/pgSQL EXECUTE ... USING var).
packages/web-ui/src/utils/columnUsage.ts 477 CTE names are statement-global, so WITH orders AS (SELECT * FROM orders ...) hides the real inner table reference.
Files Reviewed (19 files)
  • packages/web-ui/src/utils/columnUsage.ts - 6 issues
  • packages/web-ui/src/utils/columnUsageCollector.ts
  • packages/web-ui/src/components/modals/ColumnUsageModal.tsx
  • packages/web-ui/src/components/layout/sidebar/SidebarColumnItem.tsx
  • packages/web-ui/tests/utils/columnUsage.test.ts
  • packages/web-ui/tests/utils/columnUsageCollector.test.ts
  • packages/web-ui/src/i18n/locales/*.json (13 locale files; new columnUsage.* and sidebar.showColumnUsage keys)

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-5.5 · Input: 248 · Output: 1K · Cached: 143.7K

Review guidance: REVIEW.md from base branch main

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

Addressed the six review findings in columnUsage.ts. Each one is now covered by a test with the reported example plus more of the same kind:

  • Parenthesised joins: FROM (a JOIN b ON ...) is read as a join group, so its tables are bound. This covers the nested ((a join b) join c) form MySQL uses in SHOW CREATE VIEW, FROM (a, b) and ((a JOIN b)). Subqueries, ((SELECT ...)) d, (VALUES ...) and (TABLE ...) are still derived tables.
  • Numbers after $: a digit after $ now starts a number, so $100.50, $4111... and $1 are redacted in every dialect. Names such as a$1 and Postgres $tag$...$tag$ bodies are unchanged.
  • Wildcard scope: a bare * is checked against the tables of its own SELECT (up to the next UNION/INTERSECT/EXCEPT at that level), and is ignored inside EXISTS (...).
    • It is also recognised after TOP n, TOP (n) PERCENT WITH TIES, ALL, DISTINCT ON (...) and the MySQL select modifiers, and after earlier items such as EXTRACT(YEAR FROM d), OVER (ORDER BY ...) or CASE ... END.
    • RETURNING * is included.
    • INSERT INTO orders SELECT * FROM staging is no longer flagged for orders.
  • Comma joins after ON/USING: ON and USING no longer end the FROM list. ON DUPLICATE KEY / ON CONFLICT and WHEN still do. MERGE ... THEN UPDATE SET is no longer read as a relation.
  • USING without a parenthesis: it is read as a relation list only after DELETE or MERGE, never for EXECUTE ... USING var.
  • CTE names: a CTE name hides a table only after its own body (or inside it when RECURSIVE) and within the query that defines it.

Official frontend checks (pnpm run typecheck && pnpm run lint && pnpm run web:boundaries && pnpm run web:manual-parity && pnpm exec vitest --config packages/web-ui/vitest.config.ts run --maxWorkers=2) pass: 558 files, 8928 tests. GitNexus upstream impact of the changed functions reaches only ColumnUsageModal.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: a parenthesis after FROM/JOIN that opens with a table (or another parenthesis) is now read as a join group, so its tables are bound. Covered by tests for the two- and three-table nested MySQL view bodies, a Postgres group, FROM (a, b) and ((a JOIN b)). ((SELECT ...)) d stays a derived table.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: a bare * is now checked only against the tables of its own SELECT (up to a UNION/INTERSECT/EXCEPT at that level) and is ignored inside EXISTS (...). It is also found after TOP n, TOP (n) PERCENT WITH TIES, ALL, DISTINCT ON (...), the MySQL modifiers and earlier items like EXTRACT(YEAR FROM d) or CASE ... END. RETURNING * counts too. Your IN/EXISTS examples now give no match.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: a digit after $ now starts a number, so $100.50, $4111... and $1 are redacted in excerpts and the summary. The test runs mssql, mysql, sqlite, postgres and the generic dialect and asserts that no digit survives. Names like a$1 and Postgres $tag$ bodies are unchanged.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: a CTE name now hides a table only after its own body (or inside it with RECURSIVE) and within the query that defines it. WITH orders AS (SELECT * FROM orders ...) now sees the real table inside the body. Covered by tests, along with a CTE nested in a subquery and one defined after use.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: USING without a parenthesis is read as a relation list only after DELETE or MERGE at the same query level, so EXECUTE ... USING var registers nothing. The test covers EXECUTE ... USING orders.customer_id (now uncertain) and the DELETE/MERGE forms (still parsed).

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

In reply to #1479 (comment):

Fixed in 7c4323a: ON and USING no longer end the FROM list, so JOIN x ON ..., customers c (and USING (a), customers c) gives several-tables. ON DUPLICATE KEY UPDATE / ON CONFLICT ... DO UPDATE still end it, which is covered by a test.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

The red test job on 7c4323a is RowEditorPanel.test.tsx > renders PostgreSQL plugins for the row connection while MySQL is globally active: its waitFor saw 0 plugin-frame contexts instead of 2. This push only touches columnUsage.ts and its test. That file passes 5 out of 5 times locally on the same commit, and the full vitest run passed (558 files, 8928 tests), so it looks like a timing flake on the runner. A re-run of the job should clear it.

debba
debba previously approved these changes Nov 12, 2026

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @shu-chen-dot-ca, this is a thoughtful feature and it covers all of #1407. Two things I especially liked. The redaction goes through the existing sqlSplitter dialect rules, so $ numbers, N'' / X'' / E'', dollar-quoted bodies and MySQL double-quoted strings all end up as '?', and comments are dropped from excerpts and the summary. And each source reports complete, partial, unsupported, permission denied, failed or cancelled, so missing metadata never shows up as "no evidence". That honesty about what was actually checked is exactly what this panel needs.

Approving. I'd pick up the first point soon, the rest are follow-ups:

  1. Stop can't interrupt the history pass. The loop in columnUsageCollector.ts never awaits:
    for (const entry of inScope.slice(0, limits.maxHistoryEntries)) {
        if (scanned % 200 === 0 && cancelled()) {
    so the Stop click handler can't run until the loop is done, and cancelled() never sees the abort. With a large history (lots of big scripts) the UI freezes for seconds and Stop does nothing. Yielding every N entries (await new Promise((r) => setTimeout(r, 0))) before checking cancelled(), plus a test that aborts during the history scan, would fix it. The current cancellation test only aborts inside a lookup mock.
  2. ForeignKey has no ref_schema, so an incoming FK like public.invoices → sales.orders(customer_id) matches on ref_table === 'orders' and is listed as Confirmed for public.orders.customer_id. Outgoing FKs also set no objectSchema, so Open can go to the wrong table. #1407 mentions duplicate table names across schemas; until FKs carry the referenced schema, marking these as uncertain or stating it in the boundary text would be safer.
  3. computeScopes only opens a new scope for (SELECT / (WITH, so both sides of a UNION share one. SELECT customer_id FROM public.orders UNION SELECT customer_id FROM public.archive gives two "uncertain, several tables" matches. The wildcard path already splits on set operators, the same could apply here.
  4. collectColumnUsage(...).then(...) in ColumnUsageModal.tsx has no .catch. Lookup errors are handled inside the collector, but an unexpected parser throw would leave "Searching…" and Stop on screen forever.
  5. The description says the shared Modal provides Escape and focus, but Modal only wires useEscapeKey. Adding autoFocus on Close or Stop, like the other modals do, would move keyboard focus into the dialog.
  6. describe('review findings', …) in columnUsage.test.ts should be folded into the findColumnReferences / buildUsageSnippet blocks (.rules/testing.md), and a component test for ColumnUsageModal and the new SidebarColumnItem menu (which now opens when canManage is false) would be welcome.
  7. Failed history entries (status: 'error') count as evidence with nothing in the UI marking them. Tagging them would help.

About CI: the red test job is a single RowEditorPanel.test.tsx case that this PR doesn't touch and that passes locally, so it looks like a flake. I've re-run the failed job. I'll try the feature in the app before merging.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

Thanks @debba! Pushed bb64594:

  1. Stop during the history pass: the loop now yields with setTimeout(0) every 100 entries (or sooner, once a stretch of matching has run 16 ms, for big scripts) and then checks cancelled(). The new test resolves 1,500 entries and aborts from a timer, so the abort only arrives once the scan yields. It now ends cancelled with scanned < total; before this change it came back complete.

I also took the three small ones:

  1. The modal's collectColumnUsage(...) call has a .catch. An unexpected throw marks whatever was still pending as failed with the message (failPendingSources, tested), so the panel never stays on "Searching…".
  2. autoFocus is now on the Close button.
  3. The review findings tests are folded into the findColumnReferences and redactSqlLiterals blocks.

I'll take 2, 3 and 7 in a follow-up PR, along with the component tests for ColumnUsageModal and the SidebarColumnItem menu from 6:

  • 2: an incoming FK whose referenced schema can't be known will be shown as uncertain rather than Confirmed. Outgoing FKs will only get an Open target when the schema is known. The boundary text will say this too.
  • 3: split scopes on UNION/INTERSECT/EXCEPT, as the wildcard path already does.
  • 7: tag history entries that failed.

The official frontend checks pass: 558 files, 8930 tests.

@NewtTheWolf NewtTheWolf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the history-scan yield, the unexpected-error settlement and initial focus. I checked cd88ffc3 against the reviewed revision: Stop can now run between batches, the collector rejection no longer leaves pending sources searching forever, and the tests were moved into the normal feature groups. The affected frontend suites, typecheck, lint and production build pass.

The cross-schema FK problem from Debba's review is still present, and #1474 now provides the referenced-schema metadata on main. I reproduced the false positive with an independent collector test:

  1. Inspect public.orders.customer_id.
  2. Return a snapshot containing public.invoices.customer_id -> sales.orders.customer_id, with ref_schema: "sales" on that FK.
  3. Expect no incoming FK for public.orders.

The collector instead emits an incoming item with confidence: "confirmed" and objectSchema: "public". It compares only ref_table and ref_column. The outgoing path also omits the referenced schema when building its navigation target.

Please integrate current main and use the referenced-schema identity in both FK directions, including the Open target. Add same-table-name/different-schema regressions so the panel cannot confidently report or navigate to the wrong relation. The independent test failed on this head and was removed afterward; the worktree is clean. I have not run a native column-usage flow in this pass.

Requesting changes for that remaining metadata-identity issue. The yield and error-handling corrections are good.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

Thanks @NewtTheWolf. Rebased onto current main (5199006, which includes #1474) with no conflicts, and added 2868b79:

  • Incoming: a key in the target schema's snapshot counts only when its referenced schema (getReferencedSchema(fk, target.schema), so ref_schema, else the source schema as on main) is the target's schema. public.invoices.customer_id -> sales.orders.customer_id with ref_schema: "sales" no longer shows up for public.orders.customer_id.
  • Outgoing: each key now carries objectSchema from the same helper, so Open goes to sales.customers for a key into sales rather than the sidebar's schema.

New foreign keys across schemas tests in columnUsageCollector.test.ts cover your case, a table with keys into public, sales and archive (only public is kept), outgoing keys to customers in two schemas (each opens its own), a missing / null / empty ref_schema (treated as the source schema, like getReferencedSchema on main), and a target without a schema (nothing compared). 4 of the 5 failed before the change.

Official frontend checks (pnpm run typecheck && pnpm run lint && pnpm run web:boundaries && pnpm run web:manual-parity && pnpm exec vitest --config packages/web-ui/vitest.config.ts run --maxWorkers=2) pass: 561 files, 9003 tests. GitNexus upstream impact of collectColumnUsage is LOW (only ColumnUsageModal and the sidebar menu).

@NewtTheWolf NewtTheWolf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the referenced-schema checks and navigation target. The original public.invoices -> sales.orders regression now passes on 7ad25cd9, and the 63 affected collector/parser tests, typecheck, lint and production build pass.

I found a remaining catalog-identity case in the same path. For a PostgreSQL target public.orders.customer_id, I tested three valid distinct references: "PUBLIC".orders.customer_id, public."Orders".customer_id, and public.orders."CUSTOMER_ID". The incoming matcher emits a confirmed item for each because sameName case-folds the catalog names. These are distinct identifiers under PostgreSQL's quoted-identifier rules.

An independent metadata fixture with ordinary columns and both table names runs four cases: the original different-schema case passes, while all three quoted-name cases fail the assertion that no incoming item should be confirmed. The temporary probe was removed afterward; the worktree is clean. This was a collector test, not a native database session.

Please apply the connection dialect's identifier-identity rules to the FK schema/table/column comparisons and add these quoted PostgreSQL regressions. The getReferencedSchema fallback and outgoing schema propagation are good; I am keeping changes requested for the remaining false confirmations.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

Thanks @NewtTheWolf, good catch. Pushed a9c4846:

  • Identifier rules per dialect: identifierRulesFor(dialect) says how unquoted names fold and how catalog names compare.
    • PostgreSQL folds unquoted names to lower case (ASCII only) and Oracle to upper case; both compare catalog names exactly.
    • MySQL/MariaDB compare database and table names exactly, since they are case-sensitive with the Linux default lower_case_table_names=0, and column names without case.
    • SQL Server and SQLite stay case-insensitive.
  • Foreign keys: both directions (schema, table and column), the snapshot column lookup and the history entry's database follow those rules. Your three cases, "PUBLIC".orders.customer_id, public."Orders".customer_id and public.orders."CUSTOMER_ID", give no incoming item for public.orders.customer_id, and an outgoing key on "CUSTOMER_ID" is no longer listed for customer_id. On MySQL a key into Orders is no longer confirmed for orders, while one into orders with CUSTOMER_ID still is.
  • SQL from views and history: the parser had the same mistake, since table and schema names were case-folded even when quoted. References now carry the catalog spelling, so FROM public."Orders" or "PUBLIC".orders no longer resolve to public.orders, and a mixed-case catalog target only matches its quoted spelling.
    • On MySQL the server setting can't be known from here, so a table or database written in another case (FROM Orders, SHOP.orders) is reported as uncertain with a new name-case reason instead of parsed. The reason is translated in all 13 locales.
    • An unqualified column next to both orders and Orders gives several-tables.

Tests cover:

  • the three quoted PostgreSQL FK cases, a mixed-case catalog target and "CUSTOMER_ID" on an outgoing key;
  • MySQL FKs into Orders / CUSTOMER_ID / ORDERS, with SQLite for comparison;
  • MySQL history spelling the table or database in another case;
  • the parser cases above for PostgreSQL, Oracle, MySQL, SQL Server and SQLite;
  • an unqualified column next to public."Archive", which has the column (unlike public.archive), giving several-tables.

The new test groups fail on 7ad25cd.

Not changed: aliases are still matched without case, so in PostgreSQL SELECT "O".customer_id FROM public.orders o still counts. That query fails on the server, so it only shows up for failed history entries.

Official frontend checks (pnpm run typecheck && pnpm run lint && pnpm run web:boundaries && pnpm run web:manual-parity && pnpm exec vitest --config packages/web-ui/vitest.config.ts run --maxWorkers=2) pass: 561 files, 9007 tests. GitNexus upstream impact of findColumnReferences and collectColumnUsage is LOW.

The red test job on 7ad25cd failed in the Rust step (cargo test, exit 101), which this PR doesn't touch. Main's 60ab0f1 failed the same step the same way, and cargo test on 7ad25cd passes locally (2565 tests).

@NewtTheWolf NewtTheWolf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for separating catalog names from SQL spelling. On 3b025ecd, all four independent PostgreSQL regressions now pass, and the 67 affected tests, typecheck, lint and production build pass too.

The new default rule still produces false confirmed FKs for two other supported cases:

  1. SQLite non-ASCII identifiers. I created both "Ä" and "ä" in a real in-memory SQLite database, with invoices.parent_id referencing "Ä".id. SQLite accepts the two tables as distinct. Feeding its actual table/foreign-key metadata to the collector while inspecting "ä".id produces an incoming confirmed item. identifierRulesFor('sqlite') uses insensitive, and JavaScript toLowerCase() conflates those names. SQLite's identifier folding does not make that Unicode pair equivalent.
  2. SQL Server case-sensitive collation. The same metadata fixture with dbo.Orders and dbo.orders produces a confirmed incoming key for the wrong table. SQL Server identifier identity depends on collation, so the default cannot always assume case-insensitive names; see Microsoft's identifier documentation. This second case was a collector fixture, not a live SQL Server session.

The independent probe has a positive control for the real FK target in each dialect: both controls pass, and both wrong-target assertions fail. The SQLite metadata and probe are saved in the local evidence; the temporary test file was removed.

Please make SQLite folding ASCII-aware and handle SQL Server catalog identity conservatively when collation is unknown, with regressions for both cases. PostgreSQL is corrected; I am keeping changes requested because the current rules can still label a reference to another object as confirmed.

@shu-chen-dot-ca

Copy link
Copy Markdown
Contributor Author

Thanks @NewtTheWolf. Pushed 9893f53:

  • SQLite: table and column names now compare without case for ASCII letters only (new ascii-insensitive match), so "Ä" and "ä" stay two tables. ASCII-insensitive is still needed on the catalog side because SQLite's foreign key metadata keeps the spelling of the REFERENCES clause: REFERENCES ORDERS(ID) reports ORDERS/ID for the table orders.
  • SQL Server, and any dialect without known rules: the collation isn't known, so catalog names (schema, table and column) compare exactly. SQL whose table or column differs from the catalog name only by case is reported as uncertain (name-case) rather than parsed. The name-case text now mentions columns too, in all 13 locales.

Tests:

  • SQLite: your "Ä" / "ä" case, with invoices.parent_id -> "Ä".id confirmed only for "Ä". The REFERENCES ORDERS(ID) key still counts for orders. In SQL, FROM "Ä" doesn't match ä, while ID FROM "ä" does.
  • SQL Server and generic:
    • Keys into Orders, onto CUSTOMER_ID and in DBO are dropped; only the exact one is kept. The same goes for an outgoing key on CUSTOMER_ID.
    • In SQL, dbo.Orders, CUSTOMER_ID and DBO.orders give uncertain:name-case, while dbo.orders / [dbo].[orders] still parse.

The three new test groups fail on 3b025ec. I checked the SQLite behaviour with sqlite3 first: it accepts both "Ä" and "ä", rejects "ORDERS" next to orders, and its foreign_key_list keeps the REFERENCES spelling.

Official frontend checks (pnpm run typecheck && pnpm run lint && pnpm run web:boundaries && pnpm run web:manual-parity && pnpm exec vitest --config packages/web-ui/vitest.config.ts run --maxWorkers=2) pass: 561 files, 9009 tests.

@debba debba left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through the identifier cases as well as the history cancellation. The collector now uses the referenced schema in both FK directions, PostgreSQL catalog names compare exactly, SQLite folds only ASCII letters, and SQL Server stays conservative when the collation is unknown. Those changes avoid confidently pointing at a different object. The history loop also yields before checking cancellation, and unexpected collector errors settle the pending sources.

All 69 parser/collector tests and frontend typecheck pass on this revision, including the SQLite and SQL Server regressions.

Approving, with two follow-ups:

  1. History evidence still drops the entry's status and error, so a failed query is shown without identifying it as a failed attempt. Please carry that distinction into the evidence and its display.
  2. The tests remain utility-level. A component test for initial focus, Stop, unexpected failure and opening a referenced object would complement them well.

I have now tested this head's frontend and backend in T3's integrated browser against real PostgreSQL and SQLite fixtures. Cross-schema FK filtering and Open navigation work, the view reference appears, Stop interrupts a 2,000-script history scan after only part of it is processed, and a deliberately failed view lookup settles and recovers on retry. SQLite Ä.id and ä.id remain distinct, with the positive FK only on Ä.id. Initial focus and Escape also work. Proceeding with the merge; the follow-ups above remain non-blocking.

@debba
debba merged commit 7c91046 into TabularisDB:main Nov 13, 2026
12 checks passed
@shu-chen-dot-ca
shu-chen-dot-ca deleted the feat-1407-column-usage branch November 13, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Inspect a column's relationships and usage evidence

3 participants