Skip to content

Commit ddaca5a

Browse files
fix: add CLI integration tests for detect, formats, and positional convert arg by reviewer-B
1 parent 2a71667 commit ddaca5a

5 files changed

Lines changed: 173 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- `schemaforge mcp` command (stdio and SSE modes)
3232
- `schemaforge check` command — schema consistency across directories
3333
- `schemaforge formats` command — list supported formats
34-
- `schemaforge detect_format` command — identify format from filename
34+
- `schemaforge detect` command — identify format from filename
3535
- CI/CD workflow for automated testing and publishing
3636

3737
### Changed

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SchemaForge
22

3-
> **Bidirectional ORM schema converter** — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic migrations, JSON Schema, GraphQL SDL, EF Core (C#), and Scala case classes. **11 formats, 110 direction pairs.**
3+
> **Bidirectional ORM schema converter** — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic migrations, JSON Schema, GraphQL SDL, EF Core (C#), and Scala case classes. **11 formats, 100 conversion directions.**
44
55
[![GitHub stars](https://img.shields.io/github/stars/Coding-Dev-Tools/schemaforge?style=social)](https://github.com/Coding-Dev-Tools/schemaforge/stargazers)
66
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/Coding-Dev-Tools/schemaforge)
@@ -55,7 +55,7 @@ Requires Python 3.10+.
5555

5656
### `schemaforge convert`
5757

58-
Convert a schema from one format to another. All 11 formats support conversion to and from every other format (110 direction pairs).
58+
Convert a schema from one format to another. Every format converts to and from every other format, except Alembic which is generator-only (a target, not a source) — 100 direction pairs.
5959

6060
```bash
6161
# Format-specific examples
@@ -115,6 +115,11 @@ Detects added, removed, and modified tables, columns, indexes, and constraints.
115115

116116
**Alembic** is generator-only: you can create migration scripts from any format, but parsing existing migrations back to IR is not yet supported.
117117

118+
### Limitations
119+
120+
- **Foreign keys & relationships** — the shared IR does not yet model foreign-key constraints or ORM relations, so `FOREIGN KEY` / `REFERENCES` clauses, Prisma/TypeORM relation fields, and Django `ForeignKey` fields are dropped during conversion rather than roundtripped. Tables, columns, types, defaults, indexes, unique constraints, and enums are preserved. FK support is on the roadmap.
121+
- **Alembic is generator-only** (see above) — you can generate migrations from any format but not parse them back.
122+
118123
### Format Identifiers for `--from` / `--to`
119124

120125
| CLI identifier | Format |
@@ -135,8 +140,8 @@ Detects added, removed, and modified tables, columns, indexes, and constraints.
135140

136141
SchemaForge uses a **shared Internal Representation (IR)** — all formats convert to and from this common schema definition. This architecture guarantees:
137142

138-
- **Zero-loss roundtripping**: `sql → prisma → sql` produces the same schema you started with
139-
- **Bidirectional conversion**: every supported format can convert to every other format
143+
- **High-fidelity roundtripping**: `sql → prisma → sql` reproduces tables, columns, types, defaults, indexes, unique constraints, and enums. Foreign-key/relationship constraints are not yet modeled in the IR and are dropped (see [Limitations](#limitations)).
144+
- **Bidirectional conversion**: every format can convert to every other format, except Alembic, which is generator-only (a target, not a source)
140145
- **Extensibility**: adding a new format requires only a parser and a generator — no pairwise converters
141146

142147
```
@@ -238,8 +243,8 @@ Each fixture demonstrates the same blog schema so you can compare ORM syntax sid
238243

239244
## Features
240245

241-
- **Bidirectional conversion** — all 11 formats convert to and from every other format
242-
- **Zero-loss roundtripping** — `sql → prisma → sql` reproduces the original schema exactly
246+
- **Bidirectional conversion** — every format converts to and from every other format (Alembic is generator-only: a target, not a source)
247+
- **High-fidelity roundtripping** — `sql → prisma → sql` reproduces tables, columns, types, defaults, indexes, and enums (foreign keys are not yet preserved — see [Limitations](#limitations))
243248
- **Custom type mappings** — YAML/JSON config files to override any type mapping with template variables
244249
- **VS Code extension** — live preview, schema diff, and one-click conversion from VS Code
245250
- **Alembic migration generation** — create database migration scripts from any schema format
@@ -253,7 +258,7 @@ Each fixture demonstrates the same blog schema so you can compare ORM syntax sid
253258
- **Function default preservation** — `CURRENT_TIMESTAMP`, `NOW()`, `gen_random_uuid()` survive roundtrips
254259
- **MySQL support** — ENGINE=InnoDB, AUTO_INCREMENT, DEFAULT CHARSET, COMMENT table options
255260
- **Inline ENUM** — `ENUM('small', 'medium', 'large')` column types parsed and roundtripped
256-
- **Relation preservation** — indexes, unique constraints maintained across all conversions
261+
- **Index & constraint preservation** — indexes and unique constraints maintained across all conversions (foreign-key/relationship constraints are not yet modeled — see [Limitations](#limitations))
257262
- **Custom type handling** — dialect-specific types (JSONB, etc.) pass through via CUSTOM type
258263

259264
## MCP Server

src/schemaforge/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ def main() -> None:
3636
"""SchemaForge — bidirectional ORM schema converter.
3737
3838
Convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy models,
39-
Alembic migrations, JSON Schema, and GraphQL SDL with zero-loss roundtripping.
39+
Alembic migrations, JSON Schema, and GraphQL SDL with high-fidelity
40+
roundtripping (foreign-key/relationship constraints are not yet preserved).
4041
"""
4142

4243

src/schemaforge/mcp_server.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,31 @@
77

88
from __future__ import annotations
99

10+
import os
1011
import click
1112
from pathlib import Path
1213
from typing import Any
1314

15+
16+
def _confined_directory(directory: str) -> Path:
17+
"""Resolve *directory* and confirm it stays within the allowed root.
18+
19+
The ``check`` tool iterates and reads files under the given directory. To
20+
keep an AI agent (or, in SSE mode, a remote caller) from reading arbitrary
21+
locations on the host, requests are confined to a root — the
22+
``SCHEMAFORGE_MCP_ROOT`` environment variable if set, otherwise the current
23+
working directory the server was launched in. Escaping the root raises
24+
``PermissionError``.
25+
"""
26+
root = Path(os.environ.get("SCHEMAFORGE_MCP_ROOT", Path.cwd())).resolve()
27+
target = Path(directory).resolve()
28+
if target != root and not target.is_relative_to(root):
29+
raise PermissionError(
30+
f"Directory '{directory}' is outside the allowed root '{root}'. "
31+
f"Set SCHEMAFORGE_MCP_ROOT to permit a different base directory."
32+
)
33+
return target
34+
1435
from .check import check_directory, detect_format
1536
from .convert import convert_schema
1637
from .diff import diff_schemas
@@ -156,10 +177,13 @@ def check_tool(
156177
type_map_path: Optional path to a YAML/JSON type mapping config file.
157178
"""
158179
try:
180+
safe_dir = _confined_directory(directory)
159181
result = check_directory(
160-
directory, canonical=canonical, type_map_path=type_map_path
182+
str(safe_dir), canonical=canonical, type_map_path=type_map_path
161183
)
162184
return result
185+
except PermissionError as e:
186+
return f"Error: {e}"
163187
except NotADirectoryError as e:
164188
return f"Error: {e}"
165189
except Exception as e:

tests/test_cli.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,136 @@ def test_uppercase_extension(self):
653653
assert _detect_format("schema.PRISMA") == "prisma"
654654
assert _detect_format("schema.SQL") == "sql"
655655
assert _detect_format("schema.JSON") == "json_schema"
656+
657+
658+
# ═══════════════════════════════════════════════════════════════
659+
# detect command (CLI integration)
660+
# ═══════════════════════════════════════════════════════════════
661+
662+
663+
class TestDetectCommand:
664+
"""CLI integration tests for the `schemaforge detect` command."""
665+
666+
def test_detect_sql(self):
667+
"""detect should return 'sql' for .sql files."""
668+
runner = CliRunner()
669+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f:
670+
f.write(SAMPLE_SQL)
671+
tmpfile = f.name
672+
try:
673+
result = runner.invoke(main, ["detect", tmpfile])
674+
assert result.exit_code == 0
675+
assert result.output.strip() == "sql"
676+
finally:
677+
Path(tmpfile).unlink(missing_ok=True)
678+
679+
def test_detect_prisma(self):
680+
"""detect should return 'prisma' for .prisma files."""
681+
runner = CliRunner()
682+
with tempfile.NamedTemporaryFile(mode="w", suffix=".prisma", delete=False) as f:
683+
f.write(SAMPLE_PRISMA)
684+
tmpfile = f.name
685+
try:
686+
result = runner.invoke(main, ["detect", tmpfile])
687+
assert result.exit_code == 0
688+
assert result.output.strip() == "prisma"
689+
finally:
690+
Path(tmpfile).unlink(missing_ok=True)
691+
692+
def test_detect_unknown(self):
693+
"""detect should return 'unknown' for unrecognized extensions."""
694+
runner = CliRunner()
695+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
696+
f.write("irrelevant content")
697+
tmpfile = f.name
698+
try:
699+
result = runner.invoke(main, ["detect", tmpfile])
700+
assert result.exit_code == 0
701+
assert result.output.strip() == "unknown"
702+
finally:
703+
Path(tmpfile).unlink(missing_ok=True)
704+
705+
def test_detect_verbose(self):
706+
"""detect --verbose should show detailed info."""
707+
runner = CliRunner()
708+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f:
709+
f.write(SAMPLE_SQL)
710+
tmpfile = f.name
711+
try:
712+
result = runner.invoke(main, ["detect", "--verbose", tmpfile])
713+
assert result.exit_code == 0
714+
assert "file:" in result.output
715+
assert "format:" in result.output
716+
assert "method:" in result.output
717+
finally:
718+
Path(tmpfile).unlink(missing_ok=True)
719+
720+
def test_detect_missing_file(self):
721+
"""detect on a non-existent file should error."""
722+
runner = CliRunner()
723+
result = runner.invoke(main, ["detect", "nonexistent.sql"])
724+
assert result.exit_code != 0
725+
assert "does not exist" in result.output.lower() or "Error" in result.output
726+
727+
728+
# ═══════════════════════════════════════════════════════════════
729+
# formats command (CLI integration)
730+
# ═══════════════════════════════════════════════════════════════
731+
732+
733+
class TestFormatsCommand:
734+
"""CLI integration tests for the `schemaforge formats` command."""
735+
736+
def test_formats_list(self):
737+
"""formats should list all supported formats."""
738+
runner = CliRunner()
739+
result = runner.invoke(main, ["formats"])
740+
assert result.exit_code == 0
741+
for fmt in ["sql", "prisma", "drizzle", "django", "graphql", "scala", "ef"]:
742+
assert fmt in result.output
743+
744+
def test_formats_json(self):
745+
"""formats --json should output a JSON array."""
746+
runner = CliRunner()
747+
result = runner.invoke(main, ["formats", "--json"])
748+
assert result.exit_code == 0
749+
import json
750+
parsed = json.loads(result.output.strip())
751+
assert isinstance(parsed, list)
752+
assert "sql" in parsed
753+
assert "prisma" in parsed
754+
755+
756+
# ═══════════════════════════════════════════════════════════════
757+
# convert with positional argument (CLI integration)
758+
# ═══════════════════════════════════════════════════════════════
759+
760+
761+
class TestConvertPositionalArg:
762+
"""Tests for the new positional input argument on `convert`."""
763+
764+
def test_convert_positional_arg(self):
765+
"""convert should accept a positional input argument."""
766+
runner = CliRunner()
767+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f:
768+
f.write(SAMPLE_SQL)
769+
tmpfile = f.name
770+
try:
771+
result = runner.invoke(
772+
main,
773+
["convert", tmpfile, "--from", "sql", "--to", "prisma"],
774+
)
775+
assert result.exit_code == 0
776+
assert "model users" in result.output
777+
finally:
778+
Path(tmpfile).unlink(missing_ok=True)
779+
780+
def test_convert_no_input_error(self):
781+
"""convert without any input should show error."""
782+
runner = CliRunner()
783+
result = runner.invoke(
784+
main,
785+
["convert", "--from", "sql", "--to", "prisma"],
786+
)
787+
assert result.exit_code != 0
788+
assert "no input file" in result.output.lower() or "Error" in result.output

0 commit comments

Comments
 (0)