Skip to content

Commit 8bdead6

Browse files
feat: JSON Schema format support (v1.2.0)
Add JSON Schema as the 8th supported format in SchemaForge. - JSONSchemaParser: reads JSON Schema into Schema IR (tables→definitions, columns→properties) with full type mapping (string, integer, number, boolean, object, array, date-time, uuid, enum) - JSONSchemaGenerator: outputs JSON Schema draft 2020-12 from IR with per table, required arrays, enum lists, maxLength, defaults, descriptions, and for single-table schemas - 29 new tests: parsing, generation, roundtrip, cross-format (SQL↔JSON Schema, Prisma), type config integration, edge cases - Updated CLI: --from/--to and --format include json_schema - Sample fixture: fixtures/sample.json_schema.json - 189/189 tests passing
1 parent 0ab424f commit 8bdead6

6 files changed

Lines changed: 1097 additions & 3 deletions

File tree

fixtures/sample.json_schema.json

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "sample-blog.json",
4+
"title": "BlogSchema",
5+
"description": "Sample blog schema for SchemaForge testing",
6+
"$defs": {
7+
"User": {
8+
"type": "object",
9+
"properties": {
10+
"id": {
11+
"type": "integer",
12+
"description": "Primary key"
13+
},
14+
"name": {
15+
"type": "string",
16+
"maxLength": 100,
17+
"description": "User display name"
18+
},
19+
"email": {
20+
"type": "string",
21+
"format": "email",
22+
"description": "Email address"
23+
},
24+
"role": {
25+
"type": "string",
26+
"enum": ["admin", "editor", "viewer"],
27+
"description": "User role"
28+
},
29+
"created_at": {
30+
"type": "string",
31+
"format": "date-time",
32+
"description": "Creation timestamp"
33+
},
34+
"is_active": {
35+
"type": "boolean",
36+
"default": true
37+
},
38+
"score": {
39+
"type": "number",
40+
"description": "User score"
41+
}
42+
},
43+
"required": ["id", "name", "email", "role"],
44+
"description": "Blog users"
45+
},
46+
"Post": {
47+
"type": "object",
48+
"properties": {
49+
"id": {
50+
"type": "integer"
51+
},
52+
"title": {
53+
"type": "string",
54+
"maxLength": 200
55+
},
56+
"body": {
57+
"type": "string",
58+
"description": "Post content"
59+
},
60+
"author_id": {
61+
"type": "integer"
62+
},
63+
"published_at": {
64+
"type": "string",
65+
"format": "date-time"
66+
},
67+
"tags": {
68+
"type": "array",
69+
"description": "Post tags"
70+
}
71+
},
72+
"required": ["id", "title", "author_id"],
73+
"description": "Blog posts"
74+
}
75+
}
76+
}

src/schemaforge/cli.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from .diff import diff_schemas
1111
from .type_config import TypeConfig
1212

13+
# All supported format names (used for CLI choices and detection)
14+
_FORMATS = ["sql", "prisma", "drizzle", "typeorm", "django", "sqlalchemy", "alembic", "json_schema"]
15+
1316

1417
@click.group()
1518
@click.version_option()
@@ -23,10 +26,10 @@ def main() -> None:
2326

2427
@main.command()
2528
@click.option("--from", "from_fmt", required=True,
26-
type=click.Choice(["sql", "prisma", "drizzle", "typeorm", "django", "sqlalchemy", "alembic"]),
29+
type=click.Choice(_FORMATS),
2730
help="Source format")
2831
@click.option("--to", "to_fmt", required=True,
29-
type=click.Choice(["sql", "prisma", "drizzle", "typeorm", "django", "sqlalchemy", "alembic"]),
32+
type=click.Choice(_FORMATS),
3033
help="Target format")
3134
@click.option("--input", "-i", "input_path", required=True,
3235
type=click.Path(exists=True, readable=True),
@@ -67,7 +70,7 @@ def convert(from_fmt: str, to_fmt: str, input_path: str,
6770
@click.argument("file_a", type=click.Path(exists=True, readable=True))
6871
@click.argument("file_b", type=click.Path(exists=True, readable=True))
6972
@click.option("--format", "fmt", default="auto",
70-
type=click.Choice(["auto", "sql", "prisma", "drizzle", "typeorm", "django", "sqlalchemy"]),
73+
type=click.Choice(["auto"] + _FORMATS),
7174
help="Schema format (auto = detect from extension)")
7275
def diff(file_a: str, file_b: str, fmt: str) -> None:
7376
"""Show differences between two schema files."""

src/schemaforge/convert.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from .generators.sqlalchemy_generator import SQLAlchemyGenerator
1919
from .parsers.alembic_parser import AlembicParser
2020
from .generators.alembic_generator import AlembicGenerator
21+
from .parsers.json_schema_parser import JSONSchemaParser
22+
from .generators.json_schema_generator import JSONSchemaGenerator
2123

2224
if TYPE_CHECKING:
2325
from .type_config import TypeConfig
@@ -31,6 +33,7 @@
3133
"django": (DjangoParser, DjangoGenerator),
3234
"sqlalchemy": (SQLAlchemyParser, SQLAlchemyGenerator),
3335
"alembic": (AlembicParser, AlembicGenerator),
36+
"json_schema": (JSONSchemaParser, JSONSchemaGenerator),
3437
}
3538

3639

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Generator: SchemaForge IR → JSON Schema.
2+
3+
Converts tables/columns into a JSON Schema document
4+
with each table as a ``$defs`` entry and columns as properties.
5+
"""
6+
from __future__ import annotations
7+
8+
import json
9+
from typing import Any
10+
11+
from ..ir import Schema, Table, Column, ColumnType
12+
from ..type_config import EMPTY_CONFIG, TypeConfig
13+
from ._base import resolve_type
14+
15+
16+
# JSON Schema 2020-12 draft
17+
_JSON_SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema"
18+
19+
# ColumnType → JSON Schema type + format
20+
_TYPE_TO_JSON: dict[ColumnType, dict[str, Any]] = {
21+
ColumnType.STRING: {"type": "string"},
22+
ColumnType.TEXT: {"type": "string"},
23+
ColumnType.INTEGER: {"type": "integer"},
24+
ColumnType.FLOAT: {"type": "number"},
25+
ColumnType.DECIMAL: {"type": "number"},
26+
ColumnType.BOOLEAN: {"type": "boolean"},
27+
ColumnType.DATETIME: {"type": "string", "format": "date-time"},
28+
ColumnType.DATE: {"type": "string", "format": "date"},
29+
ColumnType.TIME: {"type": "string", "format": "time"},
30+
ColumnType.JSON: {"type": "object"},
31+
ColumnType.BLOB: {"type": "string", "contentEncoding": "base64"},
32+
ColumnType.UUID: {"type": "string", "format": "uuid"},
33+
ColumnType.ENUM: {"type": "string"},
34+
ColumnType.CUSTOM: {"type": "string"},
35+
}
36+
37+
38+
class JSONSchemaGenerator:
39+
"""Convert Schema IR to a JSON Schema document."""
40+
41+
def __init__(self, type_config: TypeConfig | None = None) -> None:
42+
"""Initialize with optional custom type overrides.
43+
44+
Args:
45+
type_config: Optional custom type mapping overrides.
46+
"""
47+
self._type_config = type_config or EMPTY_CONFIG
48+
49+
def generate(self, schema: Schema) -> str:
50+
"""Generate a JSON Schema document from Schema IR.
51+
52+
Args:
53+
schema: Schema IR with tables and enums.
54+
55+
Returns:
56+
Pretty-printed JSON Schema string.
57+
"""
58+
document: dict[str, Any] = {
59+
"$schema": _JSON_SCHEMA_VERSION,
60+
"$id": "schema.json",
61+
"title": "Schema",
62+
"description": "Generated by SchemaForge",
63+
"$defs": {},
64+
}
65+
66+
for enum_type in schema.enums:
67+
enum_def = self._enum_to_def(enum_type, schema)
68+
document["$defs"][enum_type.name] = enum_def
69+
70+
for table in schema.tables:
71+
defn = self._table_to_def(table)
72+
document["$defs"][table.name] = defn
73+
74+
# If only one table, also add a root schema ref
75+
if len(schema.tables) == 1:
76+
document["$ref"] = f"#/$defs/{schema.tables[0].name}"
77+
78+
return json.dumps(document, indent=2) + "\n"
79+
80+
def _table_to_def(self, table: Table) -> dict[str, Any]:
81+
"""Convert a Table IR to a JSON Schema definition.
82+
83+
Args:
84+
table: The table to convert.
85+
86+
Returns:
87+
JSON Schema object definition.
88+
"""
89+
properties: dict[str, Any] = {}
90+
required: list[str] = []
91+
92+
for col in table.columns:
93+
prop = self._column_to_prop(col)
94+
properties[col.name] = prop
95+
if not col.nullable:
96+
required.append(col.name)
97+
98+
defn: dict[str, Any] = {
99+
"type": "object",
100+
"properties": properties,
101+
}
102+
103+
if required:
104+
defn["required"] = required
105+
106+
if table.comment:
107+
defn["description"] = table.comment
108+
109+
return defn
110+
111+
def _column_to_prop(self, col: Column) -> dict[str, Any]:
112+
"""Convert a Column IR to a JSON Schema property.
113+
114+
Args:
115+
col: The column to convert.
116+
117+
Returns:
118+
JSON Schema property object.
119+
"""
120+
prop: dict[str, Any] = {}
121+
122+
# Resolve type — check type_config overrides first
123+
if self._type_config:
124+
overridden = self._type_config.get_override(col, "json_schema")
125+
if overridden:
126+
# If override is a JSON object (starts with '{'), parse it
127+
if overridden.startswith("{") and overridden.endswith("}"):
128+
try:
129+
import json as _json
130+
prop = _json.loads(overridden)
131+
return self._add_base_annotations(prop, col)
132+
except (json.JSONDecodeError, ValueError):
133+
pass
134+
prop["type"] = overridden
135+
return self._add_base_annotations(prop, col)
136+
137+
# Default type mapping
138+
if col.type in _TYPE_TO_JSON:
139+
prop.update(_TYPE_TO_JSON[col.type])
140+
elif col.custom_type:
141+
prop["type"] = "string"
142+
prop["description_meta"] = f"custom: {col.custom_type}"
143+
else:
144+
prop["type"] = "string"
145+
146+
# ENUM → list values
147+
if col.type == ColumnType.ENUM and "values" in col.type_args:
148+
prop["enum"] = col.type_args["values"]
149+
150+
# STRING with length → maxLength
151+
if col.type == ColumnType.STRING and "length" in col.type_args:
152+
prop["maxLength"] = col.type_args["length"]
153+
154+
# DECIMAL → minimum/maximum hints if precision/scale available
155+
if col.type == ColumnType.DECIMAL:
156+
prec = col.type_args.get("precision")
157+
scale = col.type_args.get("scale")
158+
if prec:
159+
max_val = 10 ** (prec - (scale or 0)) - 10 ** -(scale or 0)
160+
prop["maximum"] = max_val
161+
if scale:
162+
prop["multipleOf"] = 10 ** -(scale)
163+
164+
return self._add_base_annotations(prop, col)
165+
166+
def _add_base_annotations(
167+
self, prop: dict[str, Any], col: Column
168+
) -> dict[str, Any]:
169+
"""Add common annotations (description, default, comment) to a property."""
170+
if col.comment:
171+
prop["description"] = col.comment
172+
if col.default is not None:
173+
# Handle fn: defaults → skip (can't represent functions in JSON Schema)
174+
if isinstance(col.default, str) and col.default.startswith("fn:"):
175+
pass
176+
else:
177+
prop["default"] = col.default
178+
return prop
179+
180+
def _enum_to_def(self, enum_type, schema: Schema) -> dict[str, Any]:
181+
"""Convert an EnumType IR to a JSON Schema enum definition.
182+
183+
Args:
184+
enum_type: The enum type to convert.
185+
schema: Full schema (for context).
186+
187+
Returns:
188+
JSON Schema definition with enum values.
189+
"""
190+
return {
191+
"type": "string",
192+
"enum": enum_type.values,
193+
"description": f"Enum: {', '.join(enum_type.values)}",
194+
}

0 commit comments

Comments
 (0)