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")
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
16 changes: 12 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 @@ -146,10 +147,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
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
86 changes: 86 additions & 0 deletions tests/test_silent_failures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Silent-failure regression tests: unsupported constructs must warn, not vanish."""

from __future__ import annotations

import pytest
import warnings
from schemaforge.generators.json_schema_generator import JSONSchemaGenerator
from schemaforge.parsers.sql_parser import SQLParser
from schemaforge.type_config import TypeConfig


def test_sql_parser_warns_on_foreign_key_constraint() -> None:
sql = """
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
SQLParser().parse(sql)
assert any("FOREIGN KEY" in str(w.message) for w in caught)


def test_sql_parser_warns_on_named_check_constraint() -> None:
sql = """
CREATE TABLE products (
price NUMERIC,
CONSTRAINT positive_price CHECK (price >= 0)
);
"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
SQLParser().parse(sql)
assert any("CHECK" in str(w.message) for w in caught)


def test_sql_parser_no_warning_for_plain_table() -> None:
sql = """
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email VARCHAR(255) NOT NULL
);
"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
SQLParser().parse(sql)
assert not caught


def test_json_schema_generator_warns_on_malformed_override() -> None:
from schemaforge.ir import Column, ColumnType

class MalformedOverrideCfg(TypeConfig):
"""Returns an override that looks like JSON but fails to parse."""

def get_override(self, col, fmt, type_args=None): # noqa: D102
return "{broken json}"

col = Column(name="email", type=ColumnType.STRING, nullable=True)
gen = JSONSchemaGenerator(type_config=MalformedOverrideCfg())
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
gen._column_to_prop(col)
assert any("failed to parse" in str(w.message) for w in caught)


def _make_column(name: str):
from schemaforge.ir import Column, ColumnType

return Column(name=name, type=ColumnType.STRING, nullable=True)


@pytest.mark.parametrize(
"sql",
[
"CREATE TABLE t (a INT, CONSTRAINT fk_a FOREIGN KEY (a) REFERENCES o(a));",
"CREATE TABLE t (a INT, FOREIGN KEY (a) REFERENCES o(a));",
],
)
def test_warning_includes_table_name(sql: str) -> None:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
SQLParser().parse(sql)
assert any("'t'" in str(w.message) for w in caught)
Loading