Skip to content

Add SQL Server support - #538

Open
gkorland with Copilot wants to merge 4 commits into
stagingfrom
copilot/add-support-for-sqlserver
Open

Add SQL Server support#538
gkorland with Copilot wants to merge 4 commits into
stagingfrom
copilot/add-support-for-sqlserver

Conversation

Copilot AI commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Microsoft SQL Server (and Azure SQL) as a supported source database, so a
sqlserver:// connection can be introspected into the schema graph and queried
with natural language.

This branch was rebuilt on top of current staging. The previous revision
was ~4 months stale and conflicted with the dispatch-layer restructuring that
landed since, so rather than resolving conflicts hunk-by-hunk the feature was
reimplemented cleanly and every finding from the review was addressed. The PR is
now a single commit against current staging and is conflict-free.

Changes

Loader — api/loaders/sqlserver_loader.py (new)

  • pymssql-based loader. Connections use as_dict=True, so rows are read by
    column name. pymssql's row2dict keeps only string keys, so positional
    access (row[0]) raises KeyError under that setting.
  • Schema-scoped end to end: parse_schema_from_url reads ?schema= and
    defaults to dbo. All four catalog queries join sys.schemas and bind the
    schema name as a parameter; sample queries are schema-qualified.
  • quote_ident doubles a literal ] so it cannot terminate a bracket
    delimiter early.
  • Connections are released in finally via _close_quietly /
    _rollback_quietly.
  • URL parsing via urlparse, so percent-encoded credentials work; optional
    ?encrypt= maps to pymssql's encryption parameter.
  • Extended-property joins filter on ep.class = 1.

Dispatch — api/core/pipeline.py, api/core/schema_loader.py

  • sqlserver:// is routed to SQLServerLoader with an sdk_only guard and a
    lazy import, mirroring Snowflake.
  • sqlserver / mssql map to the tsql sqlglot dialect in
    _DIALECT_BY_DB_TYPE.
  • sqlserver:// added to _KNOWN_DB_SCHEMES.

Sanitizer — api/sql_utils/sql_sanitizer.py

  • "Already quoted" is now dialect-scoped, so [weird] is still quoted on
    PostgreSQL/MySQL where brackets are ordinary data.
  • quote_identifier escapes ] by doubling for the [ dialect.
  • get_quote_char returns [ for sqlserver / mssql.

Dependencies

  • pymssql~=2.3.13 added to the server extra, not core dependencies, so
    the published SDK wheel is unaffected. uv.lock regenerated.

Frontend — app/src/components/modals/DatabaseModal.tsx

  • Nested protocol/port/placeholder ternaries replaced with a DB_PROFILES map.
  • SQL Server option added; the Schema field is now shared by PostgreSQL and SQL
    Server and emits ?schema=.

Docs

  • docs/sqlserver_loader.md added, matching the Snowflake/Postgres loader docs.
  • README's supported-database mentions updated.

Testing

  • tests/test_sqlserver_loader.py rewritten around FakeCursor/FakeConnection
    fakes that mimic pymssql's dict-only rows, so the cursor contract is genuinely
    exercised rather than mocked away. Covers bracket escaping and injection
    attempts, URL parsing, schema qualification, column/FK/relationship mapping,
    value serialization, DDL detection, and the query-execution paths (select,
    non-select, error, connection failure).
  • T-SQL regression tests added to tests/test_destructive_detection.py:
    SELECT TOP 10 ..., [my-table], FOR JSON PATH and ISNULL(...) are
    classified SELECT / non-destructive, while DROP, TRUNCATE,
    SELECT ... INTO, EXEC xp_cmdshell and stacked statements remain
    destructive.
  • Bracket-quoting tests added to tests/test_sql_sanitizer.py, including the
    dialect-scoping regression in both directions.

Local results:

  • uv run --extra server --extra dev pytest tests/ -k "not e2e and not test_sdk"281 passed, 1 skipped
  • uv run pylint on all changed Python files → 10.00/10
  • npx tsc --noEmit and npm run lint in app/ → clean
  • make build-prod → succeeds
  • pyspelling with the repo's spellcheck config → passes

Memory / Performance Impact

N/A — no changes to graph memory, allocators or the query engine. pymssql is
imported lazily at dispatch time, so installs without the server extra are
unaffected.

Related Issues

Supersedes the original revision of this PR; addresses all review findings on
#538.

Copilot AI linked an issue Apr 14, 2026 that may be closed by this pull request
Copilot AI changed the title [WIP] Add support for SQLServer Add SQL Server support Apr 14, 2026
Copilot AI requested a review from gkorland April 14, 2026 13:39
@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
pip/pymssql 2.3.13 UnknownUnknown

Scanned Files

  • uv.lock

@gkorland

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

@gkorland
gkorland marked this pull request as ready for review May 10, 2026 11:52

Copilot AI commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Resolved all 6 merge conflicts in commit 282f5d1. Kept the SQL Server additions from this branch and adopted main's fastmcp>=3.2.4 version bump. Regenerated uv.lock and verified all 87 tests pass.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • 0.0.0.0.in-addr.arpa
    • Triggering command: /usr/local/bin/node node server.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@gkorland gkorland left a comment

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.

Code review: SQL Server support

Recommendation: needs rework. The security posture is largely fine — all introspection queries are properly parameterized and no credential leakage was found — but the loader is functionally broken end-to-end, and the branch has drifted semantically (not just textually) from staging.

One scoping note: the Dockerfile hunk that shows up in the diff (bookwormtrixie, libtinfo6, bash) is merge-base drift — those lines are already in staging. There are no ODBC-driver/apt-key changes to review here.


🔴 Critical

C1. Dict cursor indexed by integer → schema load fails against every real database
api/loaders/sqlserver_loader.py:69 (with :169 conn.cursor(as_dict=True))

sample_results = cursor.fetchall()
return [row[0] for row in sample_results if row[0] is not None]

The cursor is created with as_dict=True, so rows are dicts keyed only by column name — pymssql's row2dict() explicitly filters to string keys:

def row2dict(row):
    """Filter dict so it only has string keys; used when as_dict == True"""
    return dict([(k, v) for k, v in row.items() if hasattr(k, 'startswith')])

So row[0] raises KeyError: 0 on the first non-empty column. The broad except Exception in load() (:186) converts that into the generic "Failed to load SQL Server database schema", hiding the root cause. Compare MySQLLoader._execute_sample_query, which correctly uses row[col_name] with DictCursor (the Postgres loader uses row[0] only because its cursor is tuple-based).

return [row[col_name] for row in sample_results if row[col_name] is not None]

The tests don't catch this because they mock out every method that touches the database.


🟠 High

H1. No schema qualification anywhere — cross-schema collisions and broken sample queries
sqlserver_loader.py:202-255 (sys.tables, no schema filter), :304, :391, :62

Everything is keyed by the bare t.name, so:

  • two same-named tables in different schemas silently overwrite each other in entities;
  • extract_columns_info filters only on WHERE t.name = %s, returning the union of columns of every same-named table across all schemas, merged into one dict — wrong types, nullability and keys;
  • _execute_sample_query runs FROM [{table_name}] unqualified, which fails with "Invalid object name" for anything outside the default schema;
  • generated SQL will reference unqualified names and fail at execution time.

PostgresLoader deliberately parses search_path and filters every catalog query by table_schema = %s. Suggest joining sys.schemas, keying entities as schema.table, passing schema_name as a second bound parameter, and qualifying the sample query.

H2. sqlserver missing from the sqlglot dialect map → ordinary SELECTs are classified destructive
api/core/pipeline.py:203-208 (_DIALECT_BY_DB_TYPE), interacting with the new '[' quote char in sql_sanitizer.py:184

detect_destructive_operation is fail-closed, and _sqlglot_dialect('sqlserver') returns None. Verified against the working tree:

detect_destructive_operation('SELECT TOP 10 * FROM users', 'sqlserver')      -> ('UNKNOWN', True)
detect_destructive_operation('SELECT * FROM [my-table]', 'sqlserver')        -> ('UNKNOWN', True)
detect_destructive_operation('SELECT a, b FROM t FOR JSON PATH', 'sqlserver')-> ('UNKNOWN', True)
sqlglot.parse('SELECT TOP 10 * FROM users', read='tsql')                     -> ['Select']   # fine

This PR's own quoting change compounds it: auto_quote_sql_identifiers runs before detect_destructive_operation, so every auto-quoted SQL Server query becomes unparseable. Read-only queries then trigger the destructive-confirmation flow and are refused outright on demo/general graphs. Fix: add "sqlserver": "tsql" (and "mssql") to _DIALECT_BY_DB_TYPE, with a regression test asserting ('SELECT', False).

H3. Loader API drift — this will not work after rebasing onto staging

Concrete breakages against current staging, beyond textual conflicts:

  1. schema_loader.py calls loader.load(user_id, url, db=db); SQLServerLoader.load(prefix, connection_url) has no db param → TypeError.
  2. text2sql.py:246 calls refresh_graph_schema(..., db=db) → same TypeError.
  3. :503 uses from api.extensions import db; the current pattern is from api.core.db_resolver import resolve_db.
  4. load_to_graph(...) is called without db=db.
  5. Dispatch moved to pipeline.get_database_type_and_loader, and the scheme allowlist is now _KNOWN_DB_SCHEMES. Without adding "sqlserver://" to both, the new frontend option fails with InvalidArgumentError("Invalid database URL format") or silently falls back to PostgresLoader.

🟡 Medium

M1. Unescaped ] in bracket-quoted identifierssqlserver_loader.py:58-66, sql_sanitizer.py:79-81. SQL Server escapes ] by doubling it. my]table[my]table] terminates the quote early. Bounded (names come from the target DB's own catalog, executed with the user's own credentials) but it violates the "never interpolate identifiers" rule; Postgres uses psycopg2.sql.Identifier. Add _quote_ident(n) = "[" + n.replace("]", "]]") + "]".

M2. pymssql added as a core dependency with an eager import. The core block is documented as "required for SDK (minimal)"; pymssql is a compiled FreeTDS binding most SDK users will never need, and a top-level import means a missing/unbuildable wheel breaks import api.core. Follow the Snowflake pattern: server extra + lazy import inside dispatch + sdk_only guard.

M3. Connections leaked on every error path:166-190 closes cursor/connection only on success. Inherited from MySQLLoader, but C1 makes it fire on every load. Also :596-611: if 'conn' in locals(): conn.rollback() raises if the failure occurred between connect() and cursor(). Use try/finally.

M4. Tests assert almost nothing. test_successful_load patches out both extract_tables_info and extract_relationships, so it only exercises URL parsing; mock_load_to_graph.assert_called_once() asserts on a mock of a mock. test_connection_error uses a plain Exception, so the pymssql.Error branch is never hit. Untested: _execute_sample_query (would have caught C1), extract_columns_info, extract_foreign_keys, execute_sql_query, refresh_graph_schema.


🔵 Low / nits

  • _is_already_quoted (sql_sanitizer.py:28-35) applies the ('[', ']') pair unconditionally, so [weird] in a Postgres/MySQL schema now reports needs_quoting() == False and is emitted unquoted — a small regression for existing dialects. Scope the quote-pair check to the active dialect.
  • _parse_sqlserver_url discards the query string, so there's no way to request Encrypt/TLS and no encryption= is passed to pymssql.connect. Matches MySQL's behavior so not a regression, but SQL Server deployments usually expect an explicit setting.
  • Bare ValueError for malformed URLs instead of InvalidArgumentError; SQLServerConnectionError (:20) is defined but never raised.
  • Three lines exceed the 120-char limit (:257, :362, :407) — will fail the pylint gate. db_name is accepted-but-unused in four methods behind # pylint: disable=unused-argument; either use it for schema scoping (see H1) or drop it.
  • DatabaseModal.tsx looks correct (no any, port 1433 wired), though the DB-specific nested ternaries now appear in three places — a Record<string, {protocol, port}> map would stop the next vendor from missing one.

Security summary

No exploitable vulnerability found:

  • ✅ All four introspection queries use %s placeholders with bound parameters; no user input is interpolated.
  • ✅ No credentials logged; user-facing errors are generic and the parsed password never reaches a log or exception message.
  • ✅ No new dangerous-statement allowance — ;-stacked statements, EXEC xp_cmdshell, sp_executesql, BULK INSERT and OPENROWSET all still classify as destructive (verified by execution). The dialect gap in H2 makes it more conservative, not less.

The only injection-shaped issue is M1, bounded to the user's own database, and should still be fixed.


Review performed against staging at the time of writing. Given H3, this needs to be rebased and re-validated against the current loader interface rather than merged textually.

Copilot AI left a comment

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.

Pull request overview

Adds SQL Server as an additional database backend in QueryWeaver, wiring it through the backend loader selection logic and exposing it in the frontend DB connection UI, with unit tests and dependency updates to support the new driver.

Changes:

  • Added SQLServerLoader (pymssql-based) and registered sqlserver:// routing in schema loading / Text2SQL selection.
  • Updated identifier quoting utilities to support SQL Server bracket quoting.
  • Extended the frontend Database connection modal to allow selecting SQL Server and generating sqlserver://... URLs, plus added unit tests and dependency locks.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
api/loaders/sqlserver_loader.py New SQL Server loader implementation (schema extraction, query execution, schema refresh).
api/core/schema_loader.py Routes sqlserver:// URLs to SQLServerLoader during schema load.
api/core/text2sql.py Routes sqlserver:// URLs to SQLServerLoader for query execution path.
api/sql_utils/sql_sanitizer.py Adds support for SQL Server bracket quoting and refactors quoted-identifier detection.
app/src/components/modals/DatabaseModal.tsx Adds SQL Server option, placeholder URL, and default port in the UI.
tests/test_sqlserver_loader.py Adds unit tests covering SQL Server URL parsing, serialization, schema modification detection, and load flow.
pyproject.toml Adds pymssql dependency; bumps fastmcp minimum.
uv.lock Locks pymssql, bumps fastmcp and adds transitive dependency (griffelib).
Dockerfile Updates Python base image and installs extra system deps needed for the container build.
.github/wordlist.txt Adds SQL Server-related terms for spellchecking.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +216 to +220
cursor.execute("""
SELECT
t.name AS table_name,
ISNULL(ep.value, '') AS table_comment
FROM sys.tables t
Comment thread api/loaders/sqlserver_loader.py Outdated
Comment on lines +68 to +69
sample_results = cursor.fetchall()
return [row[0] for row in sample_results if row[0] is not None]
Comment thread api/sql_utils/sql_sanitizer.py Outdated
Adds a `sqlserver://` loader so QueryWeaver can introspect Microsoft SQL
Server and Azure SQL instances and answer natural-language questions
against them.

Rebuilt on top of current staging and reworked to address the review
findings on #538.

- api/loaders/sqlserver_loader.py: new pymssql-based loader. Connections
  use `as_dict=True`, so rows are read by column name; positional access
  raises KeyError with that setting.
- Schema scoping: `parse_schema_from_url` reads `?schema=` (default
  `dbo`). All catalog queries join `sys.schemas` and bind the schema as a
  parameter, and sample queries are schema-qualified, so same-named
  tables in other schemas can no longer collide.
- Identifier quoting: `quote_ident` doubles a literal `]` so it cannot
  terminate a bracket delimiter early.
- Connections are released in `finally` via `_close_quietly` /
  `_rollback_quietly` instead of `if 'conn' in locals()`.
- api/core/pipeline.py: dispatch `sqlserver://` with an `sdk_only` guard
  and a lazy import, and map `sqlserver`/`mssql` to the `tsql` sqlglot
  dialect. Without the mapping the fail-closed destructive-operation
  guard classified ordinary reads such as `SELECT TOP 10 ...` as
  destructive.
- api/core/schema_loader.py: accept the `sqlserver://` scheme.
- api/sql_utils/sql_sanitizer.py: "already quoted" is now dialect-scoped,
  so `[weird]` is still quoted on PostgreSQL/MySQL where brackets are
  data; `get_quote_char` returns `[` for sqlserver/mssql.
- pyproject.toml: pymssql lives in the `server` extra, not core deps, so
  the published SDK wheel is unaffected.
- DatabaseModal.tsx: replace the nested protocol/port/placeholder
  ternaries with a `DB_PROFILES` map and expose the SQL Server option and
  its schema field.
- tests: new `tests/test_sqlserver_loader.py` uses fakes that mimic
  pymssql dict rows, so the cursor contract is actually exercised;
  added T-SQL dialect and bracket-quoting regression tests.
- docs/sqlserver_loader.md and README updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 13, 2026 18:09
@gkorland
gkorland force-pushed the copilot/add-support-for-sqlserver branch from 282f5d1 to 184e0a0 Compare August 13, 2026 18:09
Comment thread api/loaders/sqlserver_loader.py Fixed
@gkorland

Copy link
Copy Markdown
Contributor

Pushed a rebuild of this branch on top of current staging. The branch was ~4
months stale and conflicted with the dispatch-layer restructuring that landed
since, so the feature was reimplemented cleanly rather than conflict-resolved
hunk-by-hunk. Conflicts are resolved — the PR is now MERGEABLE as a single
commit.

Here is how each review finding was addressed.

Critical

C1 — dict-cursor bug in _execute_sample_query. The connection is opened
with as_dict=True, but sample values were read positionally with row[0].
pymssql's row2dict keeps only string keys under that setting, so every sample
query raised KeyError: 0 and sample values were silently lost. Now reads
row[col_name], matching MySQLLoader. tests/test_sqlserver_loader.py uses a
FakeCursor that returns dict-only rows, so this regression is actually caught
rather than mocked away.

High

H1 — no schema qualification. All four catalog queries now join
sys.schemas and bind the schema name as a parameter, and sample queries are
schema-qualified. parse_schema_from_url reads ?schema= and defaults to
dbo. This follows PostgresLoader's single-schema model, so entity keys stay
bare table names and nothing downstream in the graph/text2sql layer has to
change — but same-named tables in different schemas can no longer collide or
leak into each other's sample data.

H2 — sqlserver missing from _DIALECT_BY_DB_TYPE. Added
"sqlserver": "tsql" and "mssql": "tsql". This mattered more than it looks:
detect_destructive_operation is fail-closed, so with no dialect mapping
sqlglot could not parse ordinary T-SQL and returned ('UNKNOWN', True) — every
plain SELECT TOP 10 ... would have prompted a destructive-operation
confirmation. And because auto_quote_sql_identifiers runs before
detect_destructive_operation in text2sql.py, adding [ quoting without the
dialect made it strictly worse. There are now parametrized regression tests for
both directions in tests/test_destructive_detection.py.

H3 — loader API drift. load and refresh_graph_schema now match the
current signatures (load(prefix, connection_url, db=None),
refresh_graph_schema(graph_id, db_url, db=None)), use resolve_db(db) from
api.core.db_resolver instead of reaching into api.extensions.db, and pass
db=db through to load_to_graph.

Medium

M1 — unescaped ]. New module-level quote_ident doubles a literal ], so
my]table becomes [my]]table]. The same escaping was added to
SQLIdentifierQuoter.quote_identifier for the [ dialect, with injection-attempt
tests.

M2 — pymssql as a core dependency. Moved to the server extra alongside
snowflake-connector-python, so the published SDK wheel is unaffected. The
import at dispatch time is lazy and guarded by sdk_only.

M3 — connection leaks. Replaced the if 'conn' in locals() pattern with
try/finally plus _close_quietly / _rollback_quietly helpers, so
connections are released on every path including early failures.

M4 — weak tests. The test file was rewritten around FakeCursor /
FakeConnection fakes that mimic pymssql's actual dict-row behaviour, instead of
mocks that would accept any access pattern. Coverage now includes quoting and
injection, URL parsing (ports, defaults, percent-encoded credentials, schema,
encrypt), the C1 regression, schema qualification, column/FK/relationship
mapping, serialization, DDL detection, and all execute_sql_query paths.

Nits

  • URL parsing moved to urlparse, so percent-encoded credentials work.
  • Optional ?encrypt= maps to pymssql's encryption parameter.
  • Extended-property joins filter on ep.class = 1.
  • Removed the # pylint: disable=unused-argument suppressions on db_name.
  • DatabaseModal.tsx: nested protocol/port/placeholder ternaries replaced with a
    DB_PROFILES map.

Additional fix found while rebuilding

SQLIdentifierQuoter._is_already_quoted treated any matching delimiter pair as
"already quoted", regardless of dialect. Once [ became a real quote character
that turned into a correctness bug in the other direction: a PostgreSQL table
literally named [weird] would be seen as pre-quoted and emitted unquoted. The
check is now dialect-scoped. Two existing tests in tests/test_sql_sanitizer.py
asserted the old dialect-unaware behaviour (needs_quoting('table-name') with
the default " char); they were updated to be dialect-explicit and now assert
both directions.

Validation

  • uv run --extra server --extra dev pytest tests/ -k "not e2e and not test_sdk" → 281 passed, 1 skipped
  • uv run pylint on all changed Python files → 10.00/10
  • npx tsc --noEmit + npm run lint in app/ → clean
  • make build-prod → succeeds
  • pyspelling with the repo config → passes

CodeQL flagged the sample-value query as `py/sql-injection` (high): the
schema name reaches it from the user-supplied connection URL, and T-SQL
cannot bind identifiers as parameters.

Adds `validate_ident`, an anchored allow-list matching the existing
`SnowflakeLoader._validate_identifier` pattern. It accepts only characters
that can legitimately appear in a SQL Server object name and rejects
everything capable of escaping a bracket delimiter (`]`, quotes,
semicolons, backslashes, control characters), plus empty and over-long
names. `quote_ident` keeps doubling `]` as defence in depth.

Validation runs before the statement is built, so a hostile identifier
never reaches `cursor.execute`. `parse_schema_from_url` now validates the
schema at parse time, and `sample_size` is checked to be a positive int.

Also imports `api.core` ahead of the loader in the new test module. The
package's `__init__` eagerly pulls in the pipeline, which imports the
loaders, so importing a loader first left `graph_loader` half-built and
the file could not be run on its own.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

api/loaders/sqlserver_loader.py:475

  • extract_foreign_keys is not fully schema-scoped: it filters the parent table schema (ps.name) but not the referenced table schema (rs.name). This can emit foreign keys pointing to tables outside the loaded schema, contradicting the loader’s “single-schema” behavior and potentially confusing downstream schema reasoning.
                'type': data_type,
                'null': is_nullable,
                'key': key_type,

api/loaders/sqlserver_loader.py:96

  • SELECT DISTINCT TOP ... ORDER BY NEWID() is invalid in SQL Server because ORDER BY expressions must appear in the SELECT list when using DISTINCT. This sample-value query will fail at runtime; use a DISTINCT subquery (as the Postgres loader does) and apply the random ORDER BY in the outer query.

    # More specific patterns for schema-affecting operations
    SCHEMA_PATTERNS = [  # pylint: disable=duplicate-code
        r'^\s*CREATE\s+TABLE',
        r'^\s*CREATE\s+INDEX',
        r'^\s*CREATE\s+UNIQUE\s+INDEX',

docs/sqlserver_loader.md:68

  • This doc claims the loader extracts “Tables and views”, but the implementation currently queries only sys.tables (no sys.views). Also, the sample-values SQL shown doesn’t match the implemented random-sampling query. Update the docs to reflect the actual behavior/query (or extend the loader to include views).
- Tables and views in the selected schema
- Columns with data types, nullability, defaults and primary-key flags
- Extended properties (`MS_Description`) used as table and column descriptions
- Foreign keys, including composite keys
- Many-to-many relationships inferred from junction tables

All catalog queries join `sys.schemas` and bind the schema name as a parameter, so
a connection only ever sees the requested schema. Tables in other schemas are not
extracted and cannot collide with same-named tables in the selected schema.

### Sample Values

Sample values are collected per column with a schema-qualified, bracket-quoted
query:

```sql
SELECT DISTINCT TOP 3 [column_name]
FROM [dbo].[table_name]
WHERE [column_name] IS NOT NULL;
</details>

Copilot AI review requested due to automatic review settings August 13, 2026 18:16
# ``api.core`` must be initialised before any loader module is imported.
# ``api.core.__init__`` eagerly pulls in the pipeline, which imports the
# loaders, so importing a loader first leaves ``graph_loader`` half-built.
import api.core # noqa: F401 pylint: disable=unused-import
CodeQL still reported `py/sql-injection` after the allow-list validator:
the schema name reaching the sample query originated in the user-supplied
connection URL, and an anchored regex is not recognised as a barrier.

The tables query now selects `s.name AS schema_name` back from
`sys.schemas`, and that server-returned value is what gets interpolated
into the sample query. The URL string is still used, but only as a bound
query parameter, so it never reaches a statement body.

This is also more correct: sampling now uses the server's canonical
casing for the schema rather than whatever the URL happened to contain.

Extracts `_build_column_description` and a `_KEY_TYPES` lookup out of
`extract_columns_info` to keep it within the local-variable limit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

api/loaders/sqlserver_loader.py:60

  • validate_ident allows dots for all identifier types, but _execute_sample_query uses . as the schema/table separator. If a catalog table name contains a dot (legal in SQL Server when quoted), extract_columns_info will pass f"{schema}.{table_name}", producing multiple dots and the sample query will target the wrong object (or fail) in a non-obvious way. Consider rejecting dots in single-part schema/table identifiers so only schema.table qualification is supported.
    if not identifier or len(identifier) > 128:
        raise ValueError(
            f"Invalid {identifier_type}: {identifier!r}. "
            "Must be between 1 and 128 characters."
        )
    if not re.fullmatch(r'[A-Za-z0-9_$#@ .\-]+', identifier):
        raise ValueError(
            f"Invalid {identifier_type}: {identifier!r}. Only letters, digits, "
            "underscore, dollar, hash, at-sign, space, dot and dash are allowed."
        )

docs/sqlserver_loader.md:53

  • The SQL Server loader doc claims support for views and junction-table many-to-many inference, but the current implementation in api/loaders/sqlserver_loader.py only queries sys.tables and returns direct FK relationships. Please align the feature list with what’s actually implemented (or add view/M2M support).
- Tables and views in the selected schema
- Columns with data types, nullability, defaults and primary-key flags
- Extended properties (`MS_Description`) used as table and column descriptions
- Foreign keys, including composite keys
- Many-to-many relationships inferred from junction tables

docs/sqlserver_loader.md:116

  • This catalog-query list references sys.views, but the loader currently only queries sys.tables. Update this bullet so the doc matches the implemented queries.
The loader reads from SQL Server system catalog views:

- `sys.tables` / `sys.views` joined with `sys.schemas` — table list
- `sys.columns` joined with `sys.types` — column metadata
- `sys.indexes` / `sys.index_columns` — primary keys
- `sys.foreign_keys` / `sys.foreign_key_columns` — foreign keys
- `sys.extended_properties` (with `class = 1`) — table and column descriptions

Copilot AI review requested due to automatic review settings August 13, 2026 18:25
`catalog_schema` was optional and fell back to the URL-derived schema,
which kept the tainted value flowing into the interpolated sample query
and left CodeQL's `py/sql-injection` alert open.

It is now a required argument, so the only schema string that can reach a
statement body is the one `sys.schemas` returned. The URL schema is used
exclusively as a bound query parameter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

api/loaders/sqlserver_loader.py:59

  • validate_ident() claims underscores are allowed, but the allow-list regex does not include _, so legitimate identifiers like col_1 will be rejected (and the included tests for col_1 would fail). Add _ to the character class to match the documented behavior.
    if not identifier or len(identifier) > 128:
        raise ValueError(
            f"Invalid {identifier_type}: {identifier!r}. "
            "Must be between 1 and 128 characters."
        )
    if not re.fullmatch(r'[A-Za-z0-9_$#@ .\-]+', identifier):
        raise ValueError(
            f"Invalid {identifier_type}: {identifier!r}. Only letters, digits, "
            "underscore, dollar, hash, at-sign, space, dot and dash are allowed."
        )

Copilot AI review requested due to automatic review settings August 13, 2026 18:29

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (2)

docs/sqlserver_loader.md:49

  • The documentation claims the loader extracts "Tables and views", but the implementation currently queries only sys.tables (no sys.views). Either update the docs to say tables only, or extend the loader to include views as well.
- Tables and views in the selected schema

api/loaders/sqlserver_loader.py:536

  • extract_foreign_keys currently scopes only the parent table (ps.name = %s) but does not scope the referenced side, while extract_relationships explicitly requires both parent and referenced tables to be in the loaded schema. This can record foreign keys to tables outside the selected schema (and loses the referenced schema name in the returned dict), producing ambiguous or dangling FK metadata. Consider restricting rs.name to the same schema so FK metadata matches the schema-scoped load behavior.
            WHERE ps.name = %s AND pt.name = %s
            ORDER BY fk.name;
        """, (schema, table_name))

@gkorland

Copy link
Copy Markdown
Contributor

CI is now fully green (10/10, including Playwright and CodeQL). Three
follow-up commits landed after the rebuild, all in response to a CodeQL
py/sql-injection alert (high) on the sample-value query.

Why it fired. T-SQL cannot bind identifiers as parameters, so the schema
and table have to be interpolated. The schema originated in the user-supplied
connection URL, which gave CodeQL a genuine taint path into a statement body.

What was done, in order:

  1. validate_ident allow-list — anchored regex accepting only characters
    that can legally appear in a SQL Server object name, rejecting anything able
    to escape a bracket delimiter (], quotes, semicolons, backslashes, control
    characters) plus empty and over-128-character names. Applied before the
    statement is built, so a hostile identifier never reaches cursor.execute.
    quote_ident still doubles ] as defence in depth. This mirrors the
    existing SnowflakeLoader._validate_identifier pattern — but an anchored
    regex is not recognised as a CodeQL barrier, so the alert stayed open.

  2. Break the taint path. The tables query now selects s.name AS schema_name back from sys.schemas, and that server-returned value is
    what gets interpolated into the sample query. Besides silencing the alert
    this is more correct: sampling now uses the server's canonical casing rather
    than whatever the URL contained.

  3. Remove the fallback. catalog_schema was initially optional with an
    or schema fallback, which kept the tainted value flowing. It is now a
    required argument, so the only schema string that can reach a statement body
    is the one the catalog returned. The URL schema is used exclusively as a
    bound query parameter. CodeQL now passes with no open alerts.

Also in these commits:

  • parse_schema_from_url validates at parse time, and sample_size is checked
    to be a positive integer.
  • _build_column_description and a _KEY_TYPES lookup extracted out of
    extract_columns_info to stay within the local-variable limit (pylint 10/10).
  • The new test module imports api.core before the loader. api/core/__init__
    eagerly pulls in the pipeline, which imports the loaders, so importing a
    loader first left graph_loader half-built and the file could not be run on
    its own. (Pre-existing; tests/test_snowflake_loader.py has the same
    behaviour. Only the new file was changed — no production code was touched for
    this.)

New tests cover the validator (legitimate names, breakout attempts, empty,
over-long, error labelling), rejection before execution in the sample-query
path, and that the sample query qualifies with the catalog schema rather than
the URL schema.

Local: 305 passed, 1 skipped; pylint 10.00/10; tsc --noEmit and
npm run lint clean; make build-prod succeeds; pyspelling passes.

Ready for review — needs an approval to satisfy branch protection on staging.

@gkorland
gkorland dismissed their stale review August 13, 2026 18:36

All findings from this review (C1, H1-H3, M1-M4 and the nits) have been addressed in the rebuilt branch, plus a CodeQL py/sql-injection alert found during the rebuild. CI is fully green. Dismissing as stale.

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.

Add support for SQLServer

4 participants