Table.Columns/Constraints/Indexes/Relationships are Go maps, and every writer, reader, diff, inspector, and merge code path that iterated them directly was subject to Go's randomized map order, so identical input could produce different output (or a different in-report violation/diff order) on every run. Most visibly this showed up as bun/gorm `unique:` struct tags changing order across consecutive `make models` runs with no source change. Fixed by sorting map iteration (by Sequence then Name, or alphabetically for string-keyed maps) everywhere the order affects generated output or first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb, pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx, prisma, and typeorm readers; the shared models.GetPrimaryKey/ GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the TUI column/relationship pickers in pkg/ui.
GraphQL Schema Writer
The GraphQL writer converts RelSpec's internal database model into GraphQL Schema Definition Language (SDL) files.
Features
- Table to Type mapping: Database tables become GraphQL types
- Column to Field mapping: Table columns become type fields
- Enum support: Database enums are preserved
- Custom scalar declarations: Automatically declares DateTime, JSON, Date scalars
- Implicit relationships: Generates relationship fields from foreign keys
- Many-to-many support: Handles junction tables intelligently
- Clean output: Proper formatting, field ordering, and comments
Type Mappings
SQL to GraphQL
| SQL Type | GraphQL Type | Notes |
|---|---|---|
| bigint, integer, serial (PK) | ID | Primary keys map to ID |
| bigint, integer, int | Int | |
| text, varchar, char | String | |
| uuid (PK) | ID | UUID primary keys also map to ID |
| uuid | String | Non-PK UUIDs map to String |
| double precision, numeric, float | Float | |
| boolean | Boolean | |
| timestamp, timestamptz | DateTime | Custom scalar |
| jsonb, json | JSON | Custom scalar |
| date | Date | Custom scalar |
| Enum types | Enum | Preserves enum name |
| Arrays (e.g., text[]) | [Type] | Mapped to GraphQL lists |
Relationship Handling
The writer intelligently generates relationship fields based on foreign key constraints:
Forward Relationships (FK on this table)
-- Post table has authorId FK to User.id
CREATE TABLE post (
id bigint PRIMARY KEY,
title text NOT NULL,
author_id bigint NOT NULL REFERENCES user(id)
);
type Post {
id: ID!
title: String!
author: User! # Generated from authorId FK
}
Reverse Relationships (FK on other table)
type User {
id: ID!
email: String!
posts: [Post!]! # Reverse relationship (Post has FK to User)
}
Many-to-Many Relationships
Junction tables (tables with only PKs and FKs) are automatically detected and hidden:
CREATE TABLE post_tag (
post_id bigint NOT NULL REFERENCES post(id),
tag_id bigint NOT NULL REFERENCES tag(id),
PRIMARY KEY (post_id, tag_id)
);
type Post {
id: ID!
tags: [Tag!]! # Many-to-many through PostTag junction table
}
type Tag {
id: ID!
posts: [Post!]! # Reverse many-to-many
}
# Note: PostTag junction table is NOT included in output
Usage
Basic Usage
import (
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
"git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
)
opts := &writers.WriterOptions{
OutputPath: "schema.graphql",
}
writer := graphql.NewWriter(opts)
err := writer.WriteDatabase(db)
With Metadata Options
opts := &writers.WriterOptions{
OutputPath: "schema.graphql",
Metadata: map[string]any{
"includeScalarDeclarations": true, // Include scalar declarations
"includeComments": true, // Include field/table comments
},
}
writer := graphql.NewWriter(opts)
err := writer.WriteDatabase(db)
Write to Stdout
opts := &writers.WriterOptions{
OutputPath: "", // Empty path writes to stdout
}
writer := graphql.NewWriter(opts)
err := writer.WriteDatabase(db)
CLI Usage
# Convert PostgreSQL database to GraphQL
relspec convert --from pgsql \
--from-conn "postgres://user:pass@localhost:5432/mydb" \
--to graphql --to-path schema.graphql
# Convert GORM models to GraphQL
relspec convert --from gorm --from-path ./models \
--to graphql --to-path schema.graphql
# Convert JSON to GraphQL
relspec convert --from json --from-path schema.json \
--to graphql --to-path schema.graphql
Output Format
The generated GraphQL schema follows this structure:
- Header comment (if enabled)
- Custom scalar declarations (if any custom scalars are used)
- Enum definitions (alphabetically sorted)
- Type definitions (with fields ordered: ID first, then scalars alphabetically, then relationships)
Example Output
# Generated GraphQL Schema
# Database: myapp
scalar DateTime
scalar JSON
scalar Date
enum Role {
ADMIN
USER
MODERATOR
}
type User {
id: ID!
createdAt: DateTime!
email: String!
name: String!
role: Role!
posts: [Post!]!
profile: Profile
}
type Post {
id: ID!
content: String
published: Boolean!
publishedAt: Date
title: String!
author: User!
tags: [Tag!]!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
Metadata Options
| Option | Type | Description | Default |
|---|---|---|---|
includeScalarDeclarations |
bool | Include scalar DateTime, etc. declarations |
true |
includeComments |
bool | Include table/field descriptions as comments | true |
preservePKType |
bool | Use Int/String for PKs instead of ID | false |
Field Naming Conventions
- FK columns: Foreign key columns like
authorIdare removed from the output; instead, a relationship fieldauthoris generated - Relationship pluralization: Reverse one-to-many relationships are pluralized (e.g.,
posts,tags) - CamelCase: Field names are kept in their original casing from the database
Junction Table Detection
A table is considered a junction table if it:
- Has exactly 2 foreign key constraints
- All columns are either primary keys or foreign keys
- Has a composite primary key on the FK columns
Junction tables are automatically hidden from the GraphQL output, and many-to-many relationship fields are generated on the related types instead.
Limitations
- All tables in all schemas are flattened into a single GraphQL schema
- No support for GraphQL-specific features like directives, interfaces, or unions
- Nullable vs non-nullable is determined solely by the
NOT NULLconstraint
Example Conversion
Input (Database Schema):
CREATE TABLE user (
id bigint PRIMARY KEY,
email text NOT NULL,
created_at timestamp NOT NULL
);
CREATE TABLE post (
id bigint PRIMARY KEY,
title text NOT NULL,
author_id bigint NOT NULL REFERENCES user(id)
);
Output (GraphQL Schema):
scalar DateTime
type User {
id: ID!
createdAt: DateTime!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}