Skip to content

Commit 4e5ee52

Browse files
feat: GraphQL SDL format support as 9th schema format (v1.3.0)
- GraphQLParser parses SDL: type, enum, input definitions with !, [], @unique, @default, @id directives - GraphQLGenerator produces clean SDL from IR with proper non-null annotations, enum definitions, and type references - CLI updated: --from/--to includes graphql - 16 new tests covering parsing, generation, roundtrip, and cross-format conversion (sql, prisma, json_schema to/from graphql) - 205/205 tests passing - Sample fixture: fixtures/sample.graphql
1 parent 8bdead6 commit 4e5ee52

7 files changed

Lines changed: 1112 additions & 4 deletions

File tree

fixtures/sample.graphql

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Sample GraphQL SDL schema for SchemaForge testing."""
2+
type Query {
3+
user(id: ID!): User
4+
users: [User!]!
5+
posts: [Post!]!
6+
}
7+
8+
type Mutation {
9+
createUser(input: CreateUserInput!): User!
10+
updateUser(id: ID!, input: UpdateUserInput!): User!
11+
deleteUser(id: ID!): Boolean!
12+
}
13+
14+
enum Role {
15+
ADMIN
16+
USER
17+
MODERATOR
18+
GUEST
19+
}
20+
21+
enum PostStatus {
22+
DRAFT
23+
PUBLISHED
24+
ARCHIVED
25+
}
26+
27+
type User {
28+
id: ID!
29+
name: String!
30+
email: String! @unique
31+
age: Int
32+
role: Role!
33+
createdAt: DateTime!
34+
updatedAt: DateTime
35+
posts: [Post!]!
36+
profile: JSON
37+
}
38+
39+
type Post {
40+
id: ID!
41+
title: String!
42+
content: String!
43+
author: User!
44+
status: PostStatus!
45+
publishedAt: DateTime
46+
tags: [String!]
47+
views: Int @default(0)
48+
}
49+
50+
input CreateUserInput {
51+
name: String!
52+
email: String!
53+
age: Int
54+
role: Role!
55+
}
56+
57+
input UpdateUserInput {
58+
name: String
59+
email: String
60+
age: Int
61+
role: Role
62+
}

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "schemaforge"
7-
version = "1.0.0"
8-
description = "Bidirectional ORM schema converter — convert between SQL DDL, Prisma, Drizzle, TypeORM, and Django models with zero-loss roundtripping"
7+
version = "1.3.0"
8+
description = "Bidirectional ORM schema converter — convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy, Alembic, JSON Schema, and GraphQL SDL with zero-loss roundtripping"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = "MIT"

src/schemaforge/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .type_config import TypeConfig
1212

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

1616

1717
@click.group()
@@ -20,7 +20,7 @@ def main() -> None:
2020
"""SchemaForge — bidirectional ORM schema converter.
2121
2222
Convert between SQL DDL, Prisma, Drizzle, TypeORM, Django, SQLAlchemy models,
23-
and Alembic migration scripts with zero-loss roundtripping.
23+
Alembic migrations, JSON Schema, and GraphQL SDL with zero-loss roundtripping.
2424
"""
2525

2626

src/schemaforge/convert.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from .generators.alembic_generator import AlembicGenerator
2121
from .parsers.json_schema_parser import JSONSchemaParser
2222
from .generators.json_schema_generator import JSONSchemaGenerator
23+
from .parsers.graphql_parser import GraphQLParser
24+
from .generators.graphql_generator import GraphQLGenerator
2325

2426
if TYPE_CHECKING:
2527
from .type_config import TypeConfig
@@ -34,6 +36,7 @@
3436
"sqlalchemy": (SQLAlchemyParser, SQLAlchemyGenerator),
3537
"alembic": (AlembicParser, AlembicGenerator),
3638
"json_schema": (JSONSchemaParser, JSONSchemaGenerator),
39+
"graphql": (GraphQLParser, GraphQLGenerator),
3740
}
3841

3942

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Generator: SchemaForge IR → GraphQL SDL.
2+
3+
Converts tables/columns into GraphQL Schema Definition Language
4+
type definitions with enums, directives, and nullable/required annotations.
5+
"""
6+
from __future__ import annotations
7+
8+
from typing import Any
9+
10+
from ..ir import Schema, Table, Column, ColumnType
11+
from ..type_config import EMPTY_CONFIG, TypeConfig
12+
13+
# ColumnType → GraphQL type mapping
14+
_TYPE_TO_GRAPHQL: dict[ColumnType, str] = {
15+
ColumnType.STRING: "String",
16+
ColumnType.INTEGER: "Int",
17+
ColumnType.FLOAT: "Float",
18+
ColumnType.BOOLEAN: "Boolean",
19+
ColumnType.DATETIME: "DateTime",
20+
ColumnType.DATE: "Date",
21+
ColumnType.TIME: "Time",
22+
ColumnType.TEXT: "String",
23+
ColumnType.BLOB: "Byte",
24+
ColumnType.JSON: "JSON",
25+
ColumnType.UUID: "UUID",
26+
ColumnType.DECIMAL: "Decimal",
27+
ColumnType.ENUM: "String", # Will be overridden in field generation
28+
}
29+
30+
# CUSTOM types that should use a specific GraphQL scalar annotation
31+
_CUSTOM_TO_SCALAR: dict[str, str] = {
32+
"JSON": "JSON",
33+
"Json": "JSON",
34+
"UUID": "UUID",
35+
"UID": "UUID",
36+
"Email": "String",
37+
"PhoneNumber": "String",
38+
"DateTime": "DateTime",
39+
"Date": "Date",
40+
"Time": "Time",
41+
"Decimal": "Decimal",
42+
"BigInt": "Int",
43+
"URI": "URI",
44+
"Upload": "Upload",
45+
"Void": "Void",
46+
}
47+
48+
49+
class GraphQLGenerator:
50+
"""Convert Schema IR to GraphQL SDL."""
51+
52+
def __init__(self, type_config: TypeConfig | None = None) -> None:
53+
"""Initialize with optional custom type overrides.
54+
55+
Args:
56+
type_config: Optional custom type mapping overrides.
57+
"""
58+
self._type_config = type_config or EMPTY_CONFIG
59+
60+
def generate(self, schema: Schema) -> str:
61+
"""Generate a GraphQL SDL document from Schema IR.
62+
63+
Args:
64+
schema: Schema IR with tables and enums.
65+
66+
Returns:
67+
Formatted GraphQL SDL string.
68+
"""
69+
parts: list[str] = []
70+
71+
# Generate custom scalar declarations for types not in default set
72+
custom_scalars = self._collect_custom_scalars(schema)
73+
for scalar in sorted(custom_scalars):
74+
parts.append(f"scalar {scalar}")
75+
76+
# Generate enums
77+
for enum_type in schema.enums:
78+
parts.append(self._enum_to_sdl(enum_type))
79+
80+
# Generate types from tables
81+
for table in schema.tables:
82+
is_input = table.options.get("is_input") == "true"
83+
parts.append(self._table_to_sdl(table, is_input=is_input))
84+
85+
return "\n\n".join(parts) + "\n"
86+
87+
def _collect_custom_scalars(self, schema: Schema) -> set[str]:
88+
"""Collect GraphQL scalar types that need explicit declaration.
89+
90+
Returns:
91+
Set of scalar type names used in columns but not built-in.
92+
"""
93+
built_in = {"String", "Int", "Float", "Boolean", "ID"}
94+
scalars: set[str] = set()
95+
96+
for table in schema.tables:
97+
for col in table.columns:
98+
if col.custom_type and col.custom_type not in built_in:
99+
gql = _CUSTOM_TO_SCALAR.get(col.custom_type)
100+
if gql and gql not in built_in:
101+
scalars.add(gql)
102+
103+
return scalars
104+
105+
def _enum_to_sdl(self, enum_type) -> str:
106+
"""Convert an EnumType IR to a GraphQL enum definition.
107+
108+
Args:
109+
enum_type: The enum type to convert.
110+
111+
Returns:
112+
GraphQL enum definition string.
113+
"""
114+
values = "\n ".join(enum_type.values)
115+
return f"enum {enum_type.name} {{\n {values}\n}}"
116+
117+
def _table_to_sdl(self, table: Table, is_input: bool = False) -> str:
118+
"""Convert a Table IR to a GraphQL type definition.
119+
120+
Args:
121+
table: The table to convert.
122+
is_input: Whether to generate an 'input' vs 'type'.
123+
124+
Returns:
125+
GraphQL type definition string.
126+
"""
127+
keyword = "input" if is_input else "type"
128+
lines = [f"{keyword} {table.name} {{"]
129+
130+
for col in table.columns:
131+
lines.append(f" {self._field_to_sdl(col)}")
132+
133+
lines.append("}")
134+
return "\n".join(lines)
135+
136+
def _field_to_sdl(self, col: Column) -> str:
137+
"""Convert a Column IR to a GraphQL field definition.
138+
139+
Generates: fieldName: Type! @directive
140+
141+
Args:
142+
col: The column to convert.
143+
144+
Returns:
145+
GraphQL field definition string.
146+
"""
147+
gql_type = self._resolve_graphql_type(col)
148+
field_def = f"{col.name}: {gql_type}"
149+
150+
# Collect directives
151+
directives: list[str] = []
152+
153+
if col.unique:
154+
directives.append("@unique")
155+
156+
if col.primary_key and col.name == "id":
157+
directives.append("@id")
158+
159+
# Comments
160+
if col.comment:
161+
directives.append(f'# {col.comment.replace(chr(10), " ")}')
162+
163+
if directives:
164+
field_def += f" {' '.join(directives)}"
165+
166+
return field_def
167+
168+
def _resolve_graphql_type(self, col: Column) -> str:
169+
"""Resolve a column's type to a GraphQL type string with ! annotation.
170+
171+
Args:
172+
col: The column to resolve.
173+
174+
Returns:
175+
GraphQL type string (e.g. 'String!', '[Item]', 'Int!').
176+
"""
177+
# Check type_config override first
178+
if self._type_config:
179+
overridden = self._type_config.get_override(col, "graphql")
180+
if overridden:
181+
nullable_suffix = "!" if not col.nullable else ""
182+
return f"{overridden}{nullable_suffix}"
183+
184+
base_type = self._base_graphql_type(col)
185+
186+
# Nullable annotation: ! means required/non-null
187+
if not col.nullable:
188+
return f"{base_type}!"
189+
190+
return base_type
191+
192+
def _base_graphql_type(self, col: Column) -> str:
193+
"""Resolve the base GraphQL type name for a column.
194+
195+
Args:
196+
col: The column to resolve.
197+
198+
Returns:
199+
Base GraphQL type name.
200+
"""
201+
# STRING type with primary_key and name "id" → ID
202+
if col.type == ColumnType.STRING and col.primary_key and col.name == "id":
203+
return "ID"
204+
205+
# Handle CUSTOM types
206+
if col.type == ColumnType.CUSTOM and col.custom_type:
207+
# Check if we have a known scalar mapping
208+
mapped = _CUSTOM_TO_SCALAR.get(col.custom_type)
209+
if mapped:
210+
return mapped
211+
return col.custom_type
212+
213+
# Check type_config override
214+
if self._type_config:
215+
overridden = self._type_config.get_override(col, "graphql")
216+
if overridden:
217+
return overridden
218+
219+
# Check if there's an enum with this name (handled at schema level)
220+
# Default type mapping
221+
return _TYPE_TO_GRAPHQL.get(col.type, "String")

0 commit comments

Comments
 (0)