Skip to content

feat(sql): extend the table scanner with DDL verbs and the Access IN clause - #275

Merged
ardelperal merged 1 commit into
mainfrom
feat/issue-256
Sep 2, 2026
Merged

ardelperal merged 1 commit into
mainfrom
feat/issue-256

Conversation

@ardelperal

Copy link
Copy Markdown
Owner

What changed

Task T15 of the VBA node-discovery plan. The shared SQL table scanner — the one module every SQL path in the project uses — gained two things:

  1. CREATE TABLE / ALTER TABLE / DROP TABLE. All three targets are captured and carry access: 'write'; creating, reshaping or dropping a table are all mutations of it, so "who writes to table X" now reports them. The two-word keywords are normalized to a single interior space so clause stays a stable enum value.

  2. The Access IN "<path>" clause. The operand is an external database file, not a table, so it gets its own scanner rather than being folded into the table rows. It emits a file-kind node keyed on the normalized path (lowercase, forward slashes, collapsed/trimmed separators, UNC prefix preserved) with metadata: { external: true, backendPath }, plus a references edge tagged synthesizedBy: 'vba-external-backend'. No new NodeKind.

    The node is keyed via a synthetic file path (same trick the TempVars sweep uses) and every one of its fields derives from the normalized path alone — no line, no column, no per-caller language — so all three emitters produce a byte-identical node and the same backend named from two different queries converges on ONE node. Per-site position lives on the edge.

    Only a quoted operand matches, which is what separates the external-backend clause from the IN (1,2) value-list operator without writing a parser. The empty operand of the ODBC/dBASE connect-string form (IN "" [ODBC;DSN=x]) names a DSN, not a file we can key on, and is dropped — silent beats wrong. A first alternative also handles the VBA doubled-quote shape (… IN ""C:\x.accdb"""), which is how a SQL literal built in VBA source reaches the scanner.

All three consumers were wired up, since a change to this module is a change to all three: the in-code SQL sweep (vba/sql-wrapper.ts via a new context helper), the form RecordSource/RowSource bindings, and the saved-query extractor.

The risk: the reserved-word reject list

SQL_RESERVED_TABLE_TOKENS is the project's main defence against emitting WHERE as a table name, and adding new capturing clauses is exactly the change that could weaken it. It is untouched, and is now regression-tested explicitly rather than incidentally:

CREATE TABLE ? (…) / DROP TABLE ? — the ?-sentinel shape a dropped concat operand produces — capture nothing.

Testing

  • New file __tests__/sql-clause-coverage.test.ts: 78 tests, all green. Covers DDL verbs, the reject-list regression sweep, path normalization, the IN scanner (double/single/doubled quotes, value-list operator, empty operand, dedup), the node builder, and all three consumers end-to-end — including the acceptance criterion that the same external path named from two different queries converges on one file node.
  • npx tsc --noEmit: clean.
  • npx vitest run (full suite, after npm run build): 3273 passed, 21 failed. Those 21 are pre-existing and environmental, not caused by this change — I verified it by reverting the source changes and re-running the same four files, which produced the identical 21 failures (worktree-detection 15, npm-sdk 2, multi-repo-workspace 2, extraction 2 — all Windows EPERM temp-dir cleanup in afterEach, plus two npm-bundle packaging tests). None of them assert extraction behaviour.

Acceptance criteria I could NOT verify

  • "sqlTablesReferenced rises; no reserved word appears among the new names." I did not run the T0 coverage probe against a real Access project — no .accdb-backed corpus was available in this environment. The direction is guaranteed by construction (only new captures are added; no existing capture path was narrowed) and the reserved-word half is covered by the sweep above, but the actual before/after number is not measured here.

🤖 Generated with Claude Code

https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

…clause

The shared SQL table scanner captured names after FROM/JOIN/INTO/UPDATE
only, so two real Access idioms were invisible to the graph:

  - CREATE TABLE / ALTER TABLE / DROP TABLE targets produced no
    reference at all, so "who writes to table X" missed the code that
    creates or destroys X outright;
  - the Access `IN "<path>"` clause, which points a query at ANOTHER
    database file, looked like a plain local access — a cross-backend
    edge with no representation.

Design decisions taken:

  - The three DDL verbs join the existing clause alternation and all
    carry access: 'write'. Creating, reshaping or dropping a table are
    all mutations of the named table, so a write is the honest tag. The
    two-word keywords are normalized to a single interior space so the
    emitted `clause` stays a stable enum value.
  - The `IN` operand is a FILE, not a table, so it gets its own scanner
    (`scanSqlExternalBackends`) rather than being folded into the table
    rows. Only a QUOTED operand matches, which is what separates the
    external-backend clause from the `IN (1,2)` value-list operator
    without a parser. The empty operand of the ODBC/dBASE connect-string
    form (`IN "" [ODBC;…]`) names a DSN, not a file we can key on, and is
    dropped — silent beats wrong.
  - The external backend is a `file`-kind node: it IS a file, just not
    one this index parsed. No new NodeKind is needed, and
    `metadata.external` keeps it distinguishable from indexed files in
    every query. The edge is tagged `synthesizedBy:
    'vba-external-backend'`, matching the shape #257 Half B will need for
    linked-table origins.
  - Paths are normalized (lowercase, forward slashes, collapsed and
    trimmed separators, UNC prefix preserved) and the node is keyed on
    the normalized path via a synthetic file path — the same trick the
    TempVars sweep uses — so the same backend named from two different
    queries converges on ONE node. Every field of the node derives from
    that path alone, so all three emitters produce a byte-identical node
    and none can silently rewrite another's fields; per-site position
    lives on the edge, where call-site information belongs.
  - All three consumers of the shared scanner (in-code SQL sweep, form
    RecordSource/RowSource bindings, saved queries) were wired up, since
    a change to this module is a change to all three.

The reserved-word reject list is untouched and is now regression-tested
explicitly: its exact contents are pinned, and every one of its 21 tokens
is swept against every capturing clause including the three new DDL
verbs, so `WHERE` can still never be emitted as a table name.

Closes #256

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d
@ardelperal
ardelperal merged commit b9b99a5 into main Sep 2, 2026
5 checks passed
@ardelperal
ardelperal deleted the feat/issue-256 branch September 2, 2026 05:57
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.

1 participant