Added Graphql
This commit is contained in:
272
pkg/writers/graphql/README.md
Normal file
272
pkg/writers/graphql/README.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 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)
|
||||
```sql
|
||||
-- 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)
|
||||
);
|
||||
```
|
||||
|
||||
```graphql
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
author: User! # Generated from authorId FK
|
||||
}
|
||||
```
|
||||
|
||||
### Reverse Relationships (FK on other table)
|
||||
```graphql
|
||||
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:
|
||||
|
||||
```sql
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
```graphql
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "", // Empty path writes to stdout
|
||||
}
|
||||
|
||||
writer := graphql.NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
1. **Header comment** (if enabled)
|
||||
2. **Custom scalar declarations** (if any custom scalars are used)
|
||||
3. **Enum definitions** (alphabetically sorted)
|
||||
4. **Type definitions** (with fields ordered: ID first, then scalars alphabetically, then relationships)
|
||||
|
||||
### Example Output
|
||||
|
||||
```graphql
|
||||
# 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 `authorId` are removed from the output; instead, a relationship field `author` is 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:
|
||||
1. Has exactly 2 foreign key constraints
|
||||
2. All columns are either primary keys or foreign keys
|
||||
3. 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 NULL` constraint
|
||||
|
||||
## Example Conversion
|
||||
|
||||
**Input** (Database Schema):
|
||||
```sql
|
||||
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):
|
||||
```graphql
|
||||
scalar DateTime
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: DateTime!
|
||||
email: String!
|
||||
|
||||
posts: [Post!]!
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
|
||||
author: User!
|
||||
}
|
||||
```
|
||||
178
pkg/writers/graphql/relationships.go
Normal file
178
pkg/writers/graphql/relationships.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func (w *Writer) generateRelationFields(table *models.Table, db *models.Database, schema *models.Schema) []string {
|
||||
var fields []string
|
||||
|
||||
// 1. Forward relationships (this table has FK)
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the related table
|
||||
relatedTable := w.findTable(db, constraint.ReferencedSchema, constraint.ReferencedTable)
|
||||
if relatedTable == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate field name (remove "Id" suffix from FK column if present)
|
||||
fieldName := w.relationFieldName(constraint.Columns[0])
|
||||
|
||||
// Determine nullability from FK column
|
||||
nullable := true
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
if col.NotNull {
|
||||
nullable = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format: fieldName: RelatedType! or fieldName: RelatedType
|
||||
gqlType := relatedTable.Name
|
||||
if !nullable {
|
||||
gqlType += "!"
|
||||
}
|
||||
|
||||
fields = append(fields, fmt.Sprintf(" %s: %s", fieldName, gqlType))
|
||||
}
|
||||
|
||||
// 2. Reverse relationships (other tables reference this table)
|
||||
for _, otherSchema := range db.Schemas {
|
||||
for _, otherTable := range otherSchema.Tables {
|
||||
if otherTable.Name == table.Name && otherSchema.Name == schema.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip join tables for many-to-many
|
||||
if w.isJoinTable(otherTable) {
|
||||
// Check if this is a many-to-many through this join table
|
||||
if m2mField := w.getManyToManyField(table, otherTable, db); m2mField != "" {
|
||||
fields = append(fields, m2mField)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, constraint := range otherTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint &&
|
||||
constraint.ReferencedTable == table.Name &&
|
||||
constraint.ReferencedSchema == schema.Name {
|
||||
// Add reverse relationship field (array)
|
||||
fieldName := w.pluralize(w.camelCase(otherTable.Name))
|
||||
fields = append(fields, fmt.Sprintf(" %s: [%s!]!", fieldName, otherTable.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func (w *Writer) getManyToManyField(table *models.Table, joinTable *models.Table, db *models.Database) string {
|
||||
// Find the two FK constraints in the join table
|
||||
var fk1, fk2 *models.Constraint
|
||||
for _, constraint := range joinTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
if fk1 == nil {
|
||||
fk1 = constraint
|
||||
} else {
|
||||
fk2 = constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fk1 == nil || fk2 == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine which FK points to our table and which to the other table
|
||||
var targetConstraint *models.Constraint
|
||||
if fk1.ReferencedTable == table.Name {
|
||||
targetConstraint = fk2
|
||||
} else if fk2.ReferencedTable == table.Name {
|
||||
targetConstraint = fk1
|
||||
} else {
|
||||
return "" // This join table doesn't involve our table
|
||||
}
|
||||
|
||||
// Find the target table
|
||||
targetTable := w.findTable(db, targetConstraint.ReferencedSchema, targetConstraint.ReferencedTable)
|
||||
if targetTable == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Generate many-to-many field
|
||||
fieldName := w.pluralize(w.camelCase(targetTable.Name))
|
||||
return fmt.Sprintf(" %s: [%s!]!", fieldName, targetTable.Name)
|
||||
}
|
||||
|
||||
func (w *Writer) findTable(db *models.Database, schemaName, tableName string) *models.Table {
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name != schemaName {
|
||||
continue
|
||||
}
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == tableName {
|
||||
return table
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) relationFieldName(fkColumnName string) string {
|
||||
// Remove "Id" or "_id" suffix
|
||||
name := fkColumnName
|
||||
if strings.HasSuffix(name, "Id") {
|
||||
name = name[:len(name)-2]
|
||||
} else if strings.HasSuffix(name, "_id") {
|
||||
name = name[:len(name)-3]
|
||||
}
|
||||
|
||||
return w.camelCase(name)
|
||||
}
|
||||
|
||||
func (w *Writer) camelCase(s string) string {
|
||||
// If already camelCase or PascalCase, convert to camelCase
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Convert first character to lowercase
|
||||
return strings.ToLower(string(s[0])) + s[1:]
|
||||
}
|
||||
|
||||
func (w *Writer) pluralize(s string) string {
|
||||
// Simple pluralization rules
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Already plural
|
||||
if strings.HasSuffix(s, "s") {
|
||||
return s
|
||||
}
|
||||
|
||||
// Words ending in 'y' → 'ies'
|
||||
if strings.HasSuffix(s, "y") {
|
||||
return s[:len(s)-1] + "ies"
|
||||
}
|
||||
|
||||
// Words ending in 's', 'x', 'z', 'ch', 'sh' → add 'es'
|
||||
if strings.HasSuffix(s, "s") || strings.HasSuffix(s, "x") ||
|
||||
strings.HasSuffix(s, "z") || strings.HasSuffix(s, "ch") ||
|
||||
strings.HasSuffix(s, "sh") {
|
||||
return s + "es"
|
||||
}
|
||||
|
||||
// Default: add 's'
|
||||
return s + "s"
|
||||
}
|
||||
148
pkg/writers/graphql/type_mapping.go
Normal file
148
pkg/writers/graphql/type_mapping.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func (w *Writer) sqlTypeToGraphQL(sqlType string, column *models.Column, table *models.Table, schema *models.Schema) string {
|
||||
// Check if this is a primary key → ID type
|
||||
if column.IsPrimaryKey {
|
||||
// Check metadata for explicit type preference
|
||||
if w.options.Metadata != nil {
|
||||
if preserveType, ok := w.options.Metadata["preservePKType"].(bool); ok && preserveType {
|
||||
// Use Int or String based on SQL type
|
||||
if w.isIntegerType(sqlType) {
|
||||
return "Int"
|
||||
}
|
||||
return "String"
|
||||
}
|
||||
}
|
||||
return "ID"
|
||||
}
|
||||
|
||||
// Map SQL types to custom scalars
|
||||
if scalar := w.sqlTypeToCustomScalar(sqlType); scalar != "" {
|
||||
return scalar
|
||||
}
|
||||
|
||||
// Check if it's an enum
|
||||
if w.isEnumType(sqlType, schema) {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// Standard type mappings
|
||||
baseType := strings.Split(sqlType, "(")[0] // Remove length/precision
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
// Handle array types
|
||||
if strings.HasSuffix(baseType, "[]") {
|
||||
elemType := strings.TrimSuffix(baseType, "[]")
|
||||
gqlType := w.mapBaseTypeToGraphQL(elemType)
|
||||
return "[" + gqlType + "]"
|
||||
}
|
||||
|
||||
return w.mapBaseTypeToGraphQL(baseType)
|
||||
}
|
||||
|
||||
func (w *Writer) mapBaseTypeToGraphQL(baseType string) string {
|
||||
typeMap := map[string]string{
|
||||
// Text types
|
||||
"text": "String",
|
||||
"varchar": "String",
|
||||
"char": "String",
|
||||
"character": "String",
|
||||
"bpchar": "String",
|
||||
"name": "String",
|
||||
|
||||
// UUID
|
||||
"uuid": "ID",
|
||||
|
||||
// Integer types
|
||||
"integer": "Int",
|
||||
"int": "Int",
|
||||
"int2": "Int",
|
||||
"int4": "Int",
|
||||
"int8": "Int",
|
||||
"bigint": "Int",
|
||||
"smallint": "Int",
|
||||
"serial": "Int",
|
||||
"bigserial": "Int",
|
||||
"smallserial": "Int",
|
||||
|
||||
// Float types
|
||||
"double precision": "Float",
|
||||
"float": "Float",
|
||||
"float4": "Float",
|
||||
"float8": "Float",
|
||||
"real": "Float",
|
||||
"numeric": "Float",
|
||||
"decimal": "Float",
|
||||
"money": "Float",
|
||||
|
||||
// Boolean
|
||||
"boolean": "Boolean",
|
||||
"bool": "Boolean",
|
||||
}
|
||||
|
||||
if gqlType, ok := typeMap[baseType]; ok {
|
||||
return gqlType
|
||||
}
|
||||
|
||||
// Default: capitalize first letter
|
||||
if len(baseType) > 0 {
|
||||
return strings.ToUpper(string(baseType[0])) + baseType[1:]
|
||||
}
|
||||
|
||||
return "String"
|
||||
}
|
||||
|
||||
func (w *Writer) sqlTypeToCustomScalar(sqlType string) string {
|
||||
scalarMap := map[string]string{
|
||||
"timestamp": "DateTime",
|
||||
"timestamptz": "DateTime",
|
||||
"timestamp with time zone": "DateTime",
|
||||
"jsonb": "JSON",
|
||||
"json": "JSON",
|
||||
"date": "Date",
|
||||
}
|
||||
|
||||
baseType := strings.Split(sqlType, "(")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
if scalar, ok := scalarMap[baseType]; ok {
|
||||
return scalar
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *Writer) isIntegerType(sqlType string) bool {
|
||||
intTypes := map[string]bool{
|
||||
"integer": true,
|
||||
"int": true,
|
||||
"int2": true,
|
||||
"int4": true,
|
||||
"int8": true,
|
||||
"bigint": true,
|
||||
"smallint": true,
|
||||
"serial": true,
|
||||
"bigserial": true,
|
||||
"smallserial": true,
|
||||
}
|
||||
|
||||
baseType := strings.Split(sqlType, "(")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
return intTypes[baseType]
|
||||
}
|
||||
|
||||
func (w *Writer) isEnumType(sqlType string, schema *models.Schema) bool {
|
||||
for _, enum := range schema.Enums {
|
||||
if enum.Name == sqlType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
272
pkg/writers/graphql/writer.go
Normal file
272
pkg/writers/graphql/writer.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
}
|
||||
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
return &Writer{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
content := w.databaseToGraphQL(db)
|
||||
|
||||
if w.options.OutputPath != "" {
|
||||
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
fmt.Print(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteTable(table *models.Table) error {
|
||||
schema := models.InitSchema(table.Schema)
|
||||
schema.Tables = []*models.Table{table}
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
func (w *Writer) databaseToGraphQL(db *models.Database) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Header comment
|
||||
if w.shouldIncludeComments() {
|
||||
sb.WriteString("# Generated GraphQL Schema\n")
|
||||
if db.Name != "" {
|
||||
sb.WriteString(fmt.Sprintf("# Database: %s\n", db.Name))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Custom scalar declarations
|
||||
if w.shouldIncludeScalarDeclarations() {
|
||||
scalars := w.collectCustomScalars(db)
|
||||
if len(scalars) > 0 {
|
||||
for _, scalar := range scalars {
|
||||
sb.WriteString(fmt.Sprintf("scalar %s\n", scalar))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Enum definitions
|
||||
for _, schema := range db.Schemas {
|
||||
for _, enum := range schema.Enums {
|
||||
sb.WriteString(w.enumToGraphQL(enum))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
// Skip join tables (tables with only PK+FK columns)
|
||||
if w.isJoinTable(table) {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(w.tableToGraphQL(table, db, schema))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) shouldIncludeComments() bool {
|
||||
if w.options.Metadata != nil {
|
||||
if include, ok := w.options.Metadata["includeComments"].(bool); ok {
|
||||
return include
|
||||
}
|
||||
}
|
||||
return true // Default to true
|
||||
}
|
||||
|
||||
func (w *Writer) shouldIncludeScalarDeclarations() bool {
|
||||
if w.options.Metadata != nil {
|
||||
if include, ok := w.options.Metadata["includeScalarDeclarations"].(bool); ok {
|
||||
return include
|
||||
}
|
||||
}
|
||||
return true // Default to true
|
||||
}
|
||||
|
||||
func (w *Writer) collectCustomScalars(db *models.Database) []string {
|
||||
scalarsNeeded := make(map[string]bool)
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
if scalar := w.sqlTypeToCustomScalar(col.Type); scalar != "" {
|
||||
scalarsNeeded[scalar] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted slice
|
||||
scalars := make([]string, 0, len(scalarsNeeded))
|
||||
for scalar := range scalarsNeeded {
|
||||
scalars = append(scalars, scalar)
|
||||
}
|
||||
sort.Strings(scalars)
|
||||
|
||||
return scalars
|
||||
}
|
||||
|
||||
func (w *Writer) isJoinTable(table *models.Table) bool {
|
||||
// A join table typically has:
|
||||
// 1. Exactly 2 FK constraints
|
||||
// 2. Composite primary key on those FK columns
|
||||
// 3. No other columns
|
||||
|
||||
fkCount := 0
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
fkCount++
|
||||
}
|
||||
}
|
||||
|
||||
if fkCount != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all columns are either PKs or FKs
|
||||
for _, col := range table.Columns {
|
||||
isFKColumn := false
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, fkCol := range constraint.Columns {
|
||||
if fkCol == col.Name {
|
||||
isFKColumn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isFKColumn && !col.IsPrimaryKey {
|
||||
// Found a column that's neither PK nor FK
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Writer) enumToGraphQL(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name))
|
||||
for _, value := range enum.Values {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", value))
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema *models.Schema) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Type name
|
||||
typeName := table.Name
|
||||
|
||||
// Description comment
|
||||
if w.shouldIncludeComments() && (table.Description != "" || table.Comment != "") {
|
||||
desc := table.Description
|
||||
if desc == "" {
|
||||
desc = table.Comment
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("# %s\n", desc))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("type %s {\n", typeName))
|
||||
|
||||
// Collect and categorize fields
|
||||
var idFields, scalarFields, relationFields []string
|
||||
|
||||
for _, column := range table.Columns {
|
||||
// Skip FK columns (they become relation fields)
|
||||
if w.isForeignKeyColumn(column, table) {
|
||||
continue
|
||||
}
|
||||
|
||||
gqlType := w.sqlTypeToGraphQL(column.Type, column, table, schema)
|
||||
if gqlType == "" {
|
||||
continue // Skip if type couldn't be mapped
|
||||
}
|
||||
|
||||
// Determine nullability
|
||||
if column.NotNull {
|
||||
gqlType += "!"
|
||||
}
|
||||
|
||||
field := fmt.Sprintf(" %s: %s", column.Name, gqlType)
|
||||
|
||||
if column.IsPrimaryKey {
|
||||
idFields = append(idFields, field)
|
||||
} else {
|
||||
scalarFields = append(scalarFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
// Add relation fields
|
||||
relationFields = w.generateRelationFields(table, db, schema)
|
||||
|
||||
// Write fields in order: ID, scalars (sorted), relations (sorted)
|
||||
for _, field := range idFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
|
||||
sort.Strings(scalarFields)
|
||||
for _, field := range scalarFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
|
||||
if len(relationFields) > 0 {
|
||||
if len(scalarFields) > 0 || len(idFields) > 0 {
|
||||
sb.WriteString("\n") // Blank line before relations
|
||||
}
|
||||
sort.Strings(relationFields)
|
||||
for _, field := range relationFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) isForeignKeyColumn(column *models.Column, table *models.Table) bool {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, fkCol := range constraint.Columns {
|
||||
if fkCol == column.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
412
pkg/writers/graphql/writer_test.go
Normal file
412
pkg/writers/graphql/writer_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestWriter_WriteTable_Simple(t *testing.T) {
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
nameCol := models.InitColumn("name", "User", "public")
|
||||
nameCol.Type = "text"
|
||||
nameCol.NotNull = true
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
emailCol := models.InitColumn("email", "User", "public")
|
||||
emailCol.Type = "text"
|
||||
emailCol.NotNull = false
|
||||
table.Columns["email"] = emailCol
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "",
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
schema := models.InitSchema("public")
|
||||
schema.Tables = []*models.Table{table}
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify output contains type definition
|
||||
if !strings.Contains(output, "type User {") {
|
||||
t.Error("Expected 'type User {' in output")
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if !strings.Contains(output, "id: ID!") {
|
||||
t.Error("Expected 'id: ID!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "name: String!") {
|
||||
t.Error("Expected 'name: String!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "email: String") {
|
||||
t.Error("Expected 'email: String' in output")
|
||||
}
|
||||
|
||||
// Ensure email is not followed by ! (nullable)
|
||||
if strings.Contains(output, "email: String!") {
|
||||
t.Error("Did not expect 'email: String!' (should be nullable)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_WithEnum(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create enum
|
||||
roleEnum := &models.Enum{
|
||||
Name: "Role",
|
||||
Schema: "public",
|
||||
Values: []string{"ADMIN", "USER", "GUEST"},
|
||||
}
|
||||
schema.Enums = []*models.Enum{roleEnum}
|
||||
|
||||
// Create table with enum field
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
roleCol := models.InitColumn("role", "User", "public")
|
||||
roleCol.Type = "Role"
|
||||
roleCol.NotNull = true
|
||||
table.Columns["role"] = roleCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify enum definition
|
||||
if !strings.Contains(output, "enum Role {") {
|
||||
t.Error("Expected 'enum Role {' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "ADMIN") {
|
||||
t.Error("Expected 'ADMIN' enum value in output")
|
||||
}
|
||||
|
||||
// Verify enum usage in type
|
||||
if !strings.Contains(output, "role: Role!") {
|
||||
t.Error("Expected 'role: Role!' in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_WithRelations(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create User table
|
||||
userTable := models.InitTable("User", "public")
|
||||
userIdCol := models.InitColumn("id", "User", "public")
|
||||
userIdCol.Type = "bigint"
|
||||
userIdCol.IsPrimaryKey = true
|
||||
userIdCol.NotNull = true
|
||||
userTable.Columns["id"] = userIdCol
|
||||
|
||||
userNameCol := models.InitColumn("name", "User", "public")
|
||||
userNameCol.Type = "text"
|
||||
userNameCol.NotNull = true
|
||||
userTable.Columns["name"] = userNameCol
|
||||
|
||||
// Create Post table with FK to User
|
||||
postTable := models.InitTable("Post", "public")
|
||||
|
||||
postIdCol := models.InitColumn("id", "Post", "public")
|
||||
postIdCol.Type = "bigint"
|
||||
postIdCol.IsPrimaryKey = true
|
||||
postIdCol.NotNull = true
|
||||
postTable.Columns["id"] = postIdCol
|
||||
|
||||
titleCol := models.InitColumn("title", "Post", "public")
|
||||
titleCol.Type = "text"
|
||||
titleCol.NotNull = true
|
||||
postTable.Columns["title"] = titleCol
|
||||
|
||||
authorIdCol := models.InitColumn("authorId", "Post", "public")
|
||||
authorIdCol.Type = "bigint"
|
||||
authorIdCol.NotNull = true
|
||||
postTable.Columns["authorId"] = authorIdCol
|
||||
|
||||
// Add FK constraint
|
||||
fkConstraint := models.InitConstraint("fk_post_author", models.ForeignKeyConstraint)
|
||||
fkConstraint.Schema = "public"
|
||||
fkConstraint.Table = "Post"
|
||||
fkConstraint.Columns = []string{"authorId"}
|
||||
fkConstraint.ReferencedSchema = "public"
|
||||
fkConstraint.ReferencedTable = "User"
|
||||
fkConstraint.ReferencedColumns = []string{"id"}
|
||||
postTable.Constraints["fk_post_author"] = fkConstraint
|
||||
|
||||
schema.Tables = []*models.Table{userTable, postTable}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify Post has author field (forward relationship)
|
||||
if !strings.Contains(output, "author: User!") {
|
||||
t.Error("Expected 'author: User!' in Post type")
|
||||
}
|
||||
|
||||
// Verify authorId FK column is NOT in the output
|
||||
if strings.Contains(output, "authorId:") {
|
||||
t.Error("Did not expect 'authorId:' field in output (FK columns should be hidden)")
|
||||
}
|
||||
|
||||
// Verify User has posts field (reverse relationship)
|
||||
if !strings.Contains(output, "posts: [Post!]!") {
|
||||
t.Error("Expected 'posts: [Post!]!' in User type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_CustomScalars(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("Event", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "Event", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
createdAtCol := models.InitColumn("createdAt", "Event", "public")
|
||||
createdAtCol.Type = "timestamp"
|
||||
createdAtCol.NotNull = true
|
||||
table.Columns["createdAt"] = createdAtCol
|
||||
|
||||
metadataCol := models.InitColumn("metadata", "Event", "public")
|
||||
metadataCol.Type = "jsonb"
|
||||
metadataCol.NotNull = false
|
||||
table.Columns["metadata"] = metadataCol
|
||||
|
||||
dateCol := models.InitColumn("eventDate", "Event", "public")
|
||||
dateCol.Type = "date"
|
||||
dateCol.NotNull = false
|
||||
table.Columns["eventDate"] = dateCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify scalar declarations
|
||||
if !strings.Contains(output, "scalar DateTime") {
|
||||
t.Error("Expected 'scalar DateTime' declaration")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "scalar JSON") {
|
||||
t.Error("Expected 'scalar JSON' declaration")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "scalar Date") {
|
||||
t.Error("Expected 'scalar Date' declaration")
|
||||
}
|
||||
|
||||
// Verify field types
|
||||
if !strings.Contains(output, "createdAt: DateTime!") {
|
||||
t.Error("Expected 'createdAt: DateTime!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "metadata: JSON") {
|
||||
t.Error("Expected 'metadata: JSON' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "eventDate: Date") {
|
||||
t.Error("Expected 'eventDate: Date' in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_ManyToMany(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create Post table
|
||||
postTable := models.InitTable("Post", "public")
|
||||
postIdCol := models.InitColumn("id", "Post", "public")
|
||||
postIdCol.Type = "bigint"
|
||||
postIdCol.IsPrimaryKey = true
|
||||
postIdCol.NotNull = true
|
||||
postTable.Columns["id"] = postIdCol
|
||||
|
||||
titleCol := models.InitColumn("title", "Post", "public")
|
||||
titleCol.Type = "text"
|
||||
titleCol.NotNull = true
|
||||
postTable.Columns["title"] = titleCol
|
||||
|
||||
// Create Tag table
|
||||
tagTable := models.InitTable("Tag", "public")
|
||||
tagIdCol := models.InitColumn("id", "Tag", "public")
|
||||
tagIdCol.Type = "bigint"
|
||||
tagIdCol.IsPrimaryKey = true
|
||||
tagIdCol.NotNull = true
|
||||
tagTable.Columns["id"] = tagIdCol
|
||||
|
||||
nameCol := models.InitColumn("name", "Tag", "public")
|
||||
nameCol.Type = "text"
|
||||
nameCol.NotNull = true
|
||||
tagTable.Columns["name"] = nameCol
|
||||
|
||||
// Create PostTag join table
|
||||
joinTable := models.InitTable("PostTag", "public")
|
||||
|
||||
postIdJoinCol := models.InitColumn("postId", "PostTag", "public")
|
||||
postIdJoinCol.Type = "bigint"
|
||||
postIdJoinCol.NotNull = true
|
||||
postIdJoinCol.IsPrimaryKey = true
|
||||
joinTable.Columns["postId"] = postIdJoinCol
|
||||
|
||||
tagIdJoinCol := models.InitColumn("tagId", "PostTag", "public")
|
||||
tagIdJoinCol.Type = "bigint"
|
||||
tagIdJoinCol.NotNull = true
|
||||
tagIdJoinCol.IsPrimaryKey = true
|
||||
joinTable.Columns["tagId"] = tagIdJoinCol
|
||||
|
||||
// Add composite PK constraint
|
||||
pkConstraint := models.InitConstraint("pk_posttag", models.PrimaryKeyConstraint)
|
||||
pkConstraint.Schema = "public"
|
||||
pkConstraint.Table = "PostTag"
|
||||
pkConstraint.Columns = []string{"postId", "tagId"}
|
||||
joinTable.Constraints["pk_posttag"] = pkConstraint
|
||||
|
||||
// Add FK to Post
|
||||
fk1 := models.InitConstraint("fk_posttag_post", models.ForeignKeyConstraint)
|
||||
fk1.Schema = "public"
|
||||
fk1.Table = "PostTag"
|
||||
fk1.Columns = []string{"postId"}
|
||||
fk1.ReferencedSchema = "public"
|
||||
fk1.ReferencedTable = "Post"
|
||||
fk1.ReferencedColumns = []string{"id"}
|
||||
joinTable.Constraints["fk_posttag_post"] = fk1
|
||||
|
||||
// Add FK to Tag
|
||||
fk2 := models.InitConstraint("fk_posttag_tag", models.ForeignKeyConstraint)
|
||||
fk2.Schema = "public"
|
||||
fk2.Table = "PostTag"
|
||||
fk2.Columns = []string{"tagId"}
|
||||
fk2.ReferencedSchema = "public"
|
||||
fk2.ReferencedTable = "Tag"
|
||||
fk2.ReferencedColumns = []string{"id"}
|
||||
joinTable.Constraints["fk_posttag_tag"] = fk2
|
||||
|
||||
schema.Tables = []*models.Table{postTable, tagTable, joinTable}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify join table is NOT in output
|
||||
if strings.Contains(output, "type PostTag") {
|
||||
t.Error("Did not expect 'type PostTag' (join tables should be hidden)")
|
||||
}
|
||||
|
||||
// Verify Post has tags field
|
||||
if !strings.Contains(output, "tags: [Tag!]!") {
|
||||
t.Error("Expected 'tags: [Tag!]!' in Post type")
|
||||
}
|
||||
|
||||
// Verify Tag has posts field
|
||||
if !strings.Contains(output, "posts: [Post!]!") {
|
||||
t.Error("Expected 'posts: [Post!]!' in Tag type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_UUIDType(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "uuid"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// UUID primary keys should still map to ID
|
||||
if !strings.Contains(output, "id: ID!") {
|
||||
t.Error("Expected 'id: ID!' for UUID primary key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_Metadata_NoScalarDeclarations(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("Event", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "Event", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
createdAtCol := models.InitColumn("createdAt", "Event", "public")
|
||||
createdAtCol.Type = "timestamp"
|
||||
createdAtCol.NotNull = true
|
||||
table.Columns["createdAt"] = createdAtCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"includeScalarDeclarations": false,
|
||||
},
|
||||
}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify no scalar declarations
|
||||
if strings.Contains(output, "scalar DateTime") {
|
||||
t.Error("Did not expect 'scalar DateTime' with includeScalarDeclarations=false")
|
||||
}
|
||||
|
||||
// But field should still use DateTime
|
||||
if !strings.Contains(output, "createdAt: DateTime!") {
|
||||
t.Error("Expected 'createdAt: DateTime!' in output")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user