Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -44,12 +44,12 @@ jobs:
schema-consistency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

Expand All @@ -61,7 +61,3 @@ jobs:
- name: Check schema consistency
run: |
python scripts/check_consistency.py

- name: Run schemaforge check on fixtures
run: |
schemaforge check --dir /tmp --canonical sql || true
2 changes: 1 addition & 1 deletion .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
# without this step every run failed with "not a git repository" and no
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- name: Check out the pushed branch
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup Pages
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ jobs:
environment: pypi

steps:
- uses: actions/checkout@v4 # v4.2.2 (pinned)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Set up Python 3.12
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

Expand All @@ -48,24 +48,24 @@ jobs:

- name: Publish to TestPyPI
if: ${{ inputs.pypi_target == 'testpypi' }}
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
repository-url: https://test.pypi.org/legacy/

- name: Publish to PyPI
if: ${{ inputs.pypi_target == 'pypi' || github.event_name == 'release' }}
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1

npm-publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4 # v4.2.2 (pinned)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,4 @@ node_modules
_audit_reqs.txt
nul
package-lock.json
work-log.jsonl
20 changes: 11 additions & 9 deletions fixtures/sample.alembic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@
Revises:
Create Date: 2026-05-15 03:00:00.000000
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = 'sample'
revision = "sample"
down_revision = None


def upgrade() -> None:
op.create_table('users',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('email', sa.String(255), nullable=False, unique=True),
sa.Column('role', sa.Enum('admin', 'editor', 'viewer'), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("role", sa.Enum("admin", "editor", "viewer"), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
)


def downgrade() -> None:
op.drop_table('users')
op.drop_table("users")
16 changes: 16 additions & 0 deletions src/schemaforge/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,21 @@ def _diff_tables(ta, tb) -> list[str]:
diffs.append(f'+ index "{name}"')
for name in sorted(set(a_idx.keys()) - set(b_idx.keys())):
diffs.append(f'- index "{name}"')
# Same-named indexes can silently drift: an index whose column list or
# unique flag changed previously produced NO diff line, so a schema
# comparison could report "No differences found" while query plans
# diverged. Surface both attribute changes.
for name in sorted(a_idx.keys() & b_idx.keys()):
ia, ib = a_idx[name], b_idx[name]
if ia.columns != ib.columns:
diffs.append(
f'~ index "{name}": columns=({", ".join(ia.columns)}) '
f'-> ({", ".join(ib.columns)})'
)
if ia.unique != ib.unique:
diffs.append(f'~ index "{name}": unique={ia.unique} -> {ib.unique}')

if ta.comment != tb.comment:
diffs.append(f'~ table comment: "{ta.comment}" -> "{tb.comment}"')

return diffs
11 changes: 10 additions & 1 deletion src/schemaforge/generators/json_schema_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import warnings
from typing import Any

from ..ir import Column, ColumnType, Schema, Table
Expand Down Expand Up @@ -130,7 +131,15 @@ def _column_to_prop(self, col: Column) -> dict[str, Any]:
prop = _json.loads(overridden)
return self._add_base_annotations(prop, col)
except (json.JSONDecodeError, ValueError):
pass
# A malformed override is a silent-failure trap: the
# generator would fall back to a plain type string with
# no indication the JSON override was discarded.
warnings.warn(
f"json_schema generator: type override for column "
f"'{col.name}' looks like JSON but failed to parse; "
f"using it as a plain type instead",
stacklevel=2,
)
prop["type"] = overridden
return self._add_base_annotations(prop, col)

Expand Down
59 changes: 55 additions & 4 deletions src/schemaforge/parsers/sql_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import contextlib
import re
import warnings
from typing import Any

from ..ir import Column, ColumnType, EnumType, Index, Schema, Table
Expand Down Expand Up @@ -55,6 +56,28 @@ def parse(self, text: str) -> Schema:
enum_type = self._parse_create_enum(stmt)
if enum_type:
schema.enums.append(enum_type)
elif upper.startswith("CREATE") and " INDEX " in upper:
# Standalone CREATE [UNIQUE] INDEX ... ON table (...) statements.
# Previously these were silently dropped, so a schema whose only
# change was a standalone index compared as equivalent.
idx = self._parse_standalone_index(stmt)
if idx:
target = next(
(
t
for t in schema.tables
if t.name.lower() == idx[1].lower()
),
None,
)
if target is None:
warnings.warn(
f"SQL parser: standalone index '{idx[0].name}' "
f"references unknown table '{idx[1]}'",
stacklevel=2,
)
else:
target.indexes.append(idx[0])

return schema

Expand Down Expand Up @@ -146,10 +169,17 @@ def _parse_create_table(self, stmt: str) -> Table | None:
table.indexes.append(idx)
elif upper.startswith("PRIMARY KEY"):
pass # PK handled via column constraints
elif upper.startswith("CONSTRAINT"):
pass # Foreign keys, etc.
elif upper.startswith("FOREIGN KEY") or upper.startswith("CHECK"):
pass
elif upper.startswith("CONSTRAINT") or upper.startswith(
"FOREIGN KEY"
) or upper.startswith("CHECK"):
# Silent drops here are a correctness trap: schemas that differ
# only in FK/CHECK constraints would compare as equivalent.
# Surface the loss instead of swallowing it.
warnings.warn(
f"SQL parser: ignored unsupported table constraint in "
f"'{table.name}': {defn[:60]}",
stacklevel=3,
)
else:
col = self._parse_column_def(defn)
if col:
Expand Down Expand Up @@ -363,6 +393,27 @@ def _parse_index_definition(self, defn: str) -> Index | None:
return Index(name=name, columns=columns, unique="UNIQUE" in defn.upper())
return None

def _parse_standalone_index(self, stmt: str) -> tuple[Index, str] | None:
"""Parse a standalone ``CREATE [UNIQUE] INDEX name ON table (cols)``.

Returns (Index, table_name) or None if the statement does not match.
"""
m = re.search(
r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+[`\"\[]?(\w+)[`\"\]?]?\s+"
r"ON\s+[`\"\[]?(\w+)[`\"\]?]?\s*\(([^)]+)\)",
stmt,
re.IGNORECASE,
)
if not m:
return None
columns = [c.strip().strip('"`[]') for c in m.group(3).split(",")]
idx = Index(
name=m.group(1),
columns=columns,
unique="UNIQUE" in m.group(0).upper(),
)
return idx, m.group(2)

def _parse_create_enum(self, stmt: str) -> EnumType | None:
"""Parse a CREATE TYPE ... AS ENUM statement."""
m = re.match(
Expand Down
66 changes: 66 additions & 0 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,69 @@ def test_enum_value_change(self):
def test_unsupported_format(self):
result = diff_schemas("x", "y", "badformat")
assert "Unsupported" in result


class TestIndexAndCommentDrift:
"""Regression: same-named indexes with changed columns/unique and table
comment changes previously produced NO diff (silent schema drift)."""

def test_index_columns_change(self):
ta = _table("users", indexes=[Index(name="idx_email", columns=["email"])])
tb = _table(
"users", indexes=[Index(name="idx_email", columns=["email", "tenant_id"])]
)
diffs = _diff_tables(ta, tb)
assert any('~ index "idx_email": columns=' in d for d in diffs)

def test_index_unique_change(self):
ta = _table("users", indexes=[Index(name="idx_email", columns=["email"])])
tb = _table(
"users",
indexes=[Index(name="idx_email", columns=["email"], unique=True)],
)
diffs = _diff_tables(ta, tb)
assert any('~ index "idx_email": unique=False -> True' in d for d in diffs)

def test_identical_indexes_still_clean(self):
idx = Index(name="idx_email", columns=["email"], unique=True)
ta = _table("users", indexes=[idx])
tb = _table("users", indexes=[Index(name="idx_email", columns=["email"], unique=True)])
assert _diff_tables(ta, tb) == []

def test_table_comment_change(self):
ta = Table(name="users", columns=[], comment="old")
tb = Table(name="users", columns=[], comment="new")
diffs = _diff_tables(ta, tb)
assert any('~ table comment: "old" -> "new"' in d for d in diffs)

def test_standalone_create_index_unique_drift_end_to_end(self):
"""Regression: standalone CREATE [UNIQUE] INDEX statements were silently
dropped by the SQL parser, so unique-flag drift compared as equivalent."""
from schemaforge.diff import diff_schemas

a = (
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY, email VARCHAR(255));"
"CREATE INDEX idx_email ON users (email);"
)
b = (
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY, email VARCHAR(255));"
"CREATE UNIQUE INDEX idx_email ON users (email);"
)
out = diff_schemas(a, b, "sql")
assert '~ index "idx_email": unique=False -> True' in out

def test_standalone_create_index_columns_drift_end_to_end(self):
from schemaforge.diff import diff_schemas

a = (
"CREATE TABLE users (id INTEGER PRIMARY KEY);\n"
"CREATE INDEX idx_t ON users (tenant_id);"
)
b = (
"CREATE TABLE users (id INTEGER PRIMARY KEY);\n"
"CREATE INDEX idx_t ON users (tenant_id, region);"
)
out = diff_schemas(a, b, "sql")
assert '~ index "idx_t": columns=' in out
8 changes: 6 additions & 2 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from __future__ import annotations

import pytest
import pytest # noqa: F401
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

pytest.importorskip("mcp", reason="mcp is an optional dependency")
# Skip if mcp.server.fastmcp is not importable — mcp may be installed
# but FastMCP could still be unavailable (API changes, partial installs).
# The mcp_server module catches ImportError and sets FastMCP=None, so
# we must check the actual import path used by create_server().
pytest.importorskip("mcp.server.fastmcp", reason="mcp.server.fastmcp is required for MCP server tests")

from schemaforge.mcp_server import _FORMATS, create_server

Expand Down
Loading
Loading