diff --git a/GODOC.md b/GODOC.md new file mode 100644 index 0000000..b00c4d3 --- /dev/null +++ b/GODOC.md @@ -0,0 +1,196 @@ +# RelSpec API Documentation (godoc) + +This document explains how to access and use the RelSpec API documentation. + +## Viewing Documentation Locally + +### Using `go doc` Command Line + +View package documentation: +```bash +# Main package overview +go doc + +# Specific package +go doc ./pkg/models +go doc ./pkg/readers +go doc ./pkg/writers +go doc ./pkg/ui + +# Specific type or function +go doc ./pkg/models Database +go doc ./pkg/readers Reader +go doc ./pkg/writers Writer +``` + +View all documentation for a package: +```bash +go doc -all ./pkg/models +go doc -all ./pkg/readers +go doc -all ./pkg/writers +``` + +### Using `godoc` Web Server + +**Quick Start (Recommended):** +```bash +make godoc +``` + +This will automatically install godoc if needed and start the server on port 6060. + +**Manual Installation:** +```bash +go install golang.org/x/tools/cmd/godoc@latest +godoc -http=:6060 +``` + +Then open your browser to: +``` +http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/ +``` + +## Package Documentation + +### Core Packages + +- **`pkg/models`** - Core data structures (Database, Schema, Table, Column, etc.) +- **`pkg/readers`** - Input format readers (dbml, pgsql, gorm, prisma, etc.) +- **`pkg/writers`** - Output format writers (dbml, pgsql, gorm, prisma, etc.) + +### Utility Packages + +- **`pkg/diff`** - Schema comparison and difference detection +- **`pkg/merge`** - Schema merging utilities +- **`pkg/transform`** - Validation and normalization +- **`pkg/ui`** - Interactive terminal UI for schema editing + +### Support Packages + +- **`pkg/pgsql`** - PostgreSQL-specific utilities +- **`pkg/inspector`** - Database introspection capabilities +- **`pkg/reflectutil`** - Reflection utilities for Go code analysis +- **`pkg/commontypes`** - Shared type definitions + +### Reader Implementations + +Each reader is in its own subpackage under `pkg/readers/`: + +- `pkg/readers/dbml` - DBML format reader +- `pkg/readers/dctx` - DCTX format reader +- `pkg/readers/drawdb` - DrawDB JSON reader +- `pkg/readers/graphql` - GraphQL schema reader +- `pkg/readers/json` - JSON schema reader +- `pkg/readers/yaml` - YAML schema reader +- `pkg/readers/gorm` - Go GORM models reader +- `pkg/readers/bun` - Go Bun models reader +- `pkg/readers/drizzle` - TypeScript Drizzle ORM reader +- `pkg/readers/prisma` - Prisma schema reader +- `pkg/readers/typeorm` - TypeScript TypeORM reader +- `pkg/readers/pgsql` - PostgreSQL database reader +- `pkg/readers/sqlite` - SQLite database reader + +### Writer Implementations + +Each writer is in its own subpackage under `pkg/writers/`: + +- `pkg/writers/dbml` - DBML format writer +- `pkg/writers/dctx` - DCTX format writer +- `pkg/writers/drawdb` - DrawDB JSON writer +- `pkg/writers/graphql` - GraphQL schema writer +- `pkg/writers/json` - JSON schema writer +- `pkg/writers/yaml` - YAML schema writer +- `pkg/writers/gorm` - Go GORM models writer +- `pkg/writers/bun` - Go Bun models writer +- `pkg/writers/drizzle` - TypeScript Drizzle ORM writer +- `pkg/writers/prisma` - Prisma schema writer +- `pkg/writers/typeorm` - TypeScript TypeORM writer +- `pkg/writers/pgsql` - PostgreSQL SQL writer +- `pkg/writers/sqlite` - SQLite SQL writer + +## Usage Examples + +### Reading a Schema + +```go +import ( + "git.warky.dev/wdevs/relspecgo/pkg/readers" + "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml" +) + +reader := dbml.NewReader(&readers.ReaderOptions{ + FilePath: "schema.dbml", +}) +db, err := reader.ReadDatabase() +``` + +### Writing a Schema + +```go +import ( + "git.warky.dev/wdevs/relspecgo/pkg/writers" + "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm" +) + +writer := gorm.NewWriter(&writers.WriterOptions{ + OutputPath: "./models", + PackageName: "models", +}) +err := writer.WriteDatabase(db) +``` + +### Comparing Schemas + +```go +import "git.warky.dev/wdevs/relspecgo/pkg/diff" + +result := diff.CompareDatabases(sourceDB, targetDB) +err := diff.FormatDiff(result, diff.OutputFormatText, os.Stdout) +``` + +### Merging Schemas + +```go +import "git.warky.dev/wdevs/relspecgo/pkg/merge" + +result := merge.MergeDatabases(targetDB, sourceDB, nil) +fmt.Printf("Added %d tables\n", result.TablesAdded) +``` + +## Documentation Standards + +All public APIs follow Go documentation conventions: + +- Package documentation in `doc.go` files +- Type, function, and method comments start with the item name +- Examples where applicable +- Clear description of parameters and return values +- Usage notes and caveats where relevant + +## Generating Documentation + +To regenerate documentation after code changes: + +```bash +# Verify documentation builds correctly +go doc -all ./pkg/... > /dev/null + +# Check for undocumented exports +go vet ./... +``` + +## Contributing Documentation + +When adding new packages or exported items: + +1. Add package documentation in a `doc.go` file +2. Document all exported types, functions, and methods +3. Include usage examples for complex APIs +4. Follow Go documentation style guide +5. Verify with `go doc` before committing + +## References + +- [Go Documentation Guide](https://go.dev/doc/comment) +- [Effective Go - Commentary](https://go.dev/doc/effective_go#commentary) +- [godoc Documentation](https://pkg.go.dev/golang.org/x/tools/cmd/godoc) diff --git a/Makefile b/Makefile index a63da28..b526f4e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version +.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version godoc # Binary name BINARY_NAME=relspec @@ -101,6 +101,29 @@ deps: ## Download dependencies $(GOMOD) tidy @echo "Dependencies updated" +godoc: ## Start godoc server on http://localhost:6060 + @echo "Starting godoc server..." + @GOBIN=$$(go env GOPATH)/bin; \ + if command -v godoc > /dev/null 2>&1; then \ + echo "godoc server running on http://localhost:6060"; \ + echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \ + echo "Press Ctrl+C to stop"; \ + godoc -http=:6060; \ + elif [ -f "$$GOBIN/godoc" ]; then \ + echo "godoc server running on http://localhost:6060"; \ + echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \ + echo "Press Ctrl+C to stop"; \ + $$GOBIN/godoc -http=:6060; \ + else \ + echo "godoc not installed. Installing..."; \ + go install golang.org/x/tools/cmd/godoc@latest; \ + echo "godoc installed. Starting server..."; \ + echo "godoc server running on http://localhost:6060"; \ + echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \ + echo "Press Ctrl+C to stop"; \ + $$GOBIN/godoc -http=:6060; \ + fi + start: docker-up ## Alias for docker-up (start PostgreSQL test database) stop: docker-down ## Alias for docker-down (stop PostgreSQL test database) diff --git a/TODO.md b/TODO.md index b3cf700..beb7bc7 100644 --- a/TODO.md +++ b/TODO.md @@ -25,7 +25,7 @@ - [✔️] Basic UI (I went with tview) - [✔️] Save / Load Database - [✔️] Schemas / Domains / Tables -- [ ] Add Relations +- [✔️] Add Relations - [ ] Add Indexes - [ ] Add Views - [ ] Add Sequences @@ -34,7 +34,7 @@ ## Documentation -- [ ] API documentation (godoc) +- [✔️] API documentation (godoc) - [ ] Usage examples for each format combination ## Advanced Features diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..d9b5b65 --- /dev/null +++ b/doc.go @@ -0,0 +1,108 @@ +// Package relspecgo provides bidirectional conversion between database schema formats. +// +// RelSpec is a comprehensive database schema tool that reads, writes, and transforms +// database schemas across multiple formats including live databases, ORM models, +// schema definition languages, and data interchange formats. +// +// # Features +// +// - Read from 15+ formats: PostgreSQL, SQLite, DBML, GORM, Prisma, Drizzle, and more +// - Write to 15+ formats: SQL, ORM models, schema definitions, JSON/YAML +// - Interactive TUI editor for visual schema management +// - Schema diff and merge capabilities +// - Format-agnostic intermediate representation +// +// # Architecture +// +// RelSpec uses a hub-and-spoke architecture with models.Database as the central type: +// +// Input Format → Reader → models.Database → Writer → Output Format +// +// This allows any supported input format to be converted to any supported output format +// without requiring N² conversion implementations. +// +// # Key Packages +// +// - pkg/models: Core data structures (Database, Schema, Table, Column, etc.) +// - pkg/readers: Input format readers (dbml, pgsql, gorm, etc.) +// - pkg/writers: Output format writers (dbml, pgsql, gorm, etc.) +// - pkg/ui: Interactive terminal UI for schema editing +// - pkg/diff: Schema comparison and difference detection +// - pkg/merge: Schema merging utilities +// - pkg/transform: Validation and normalization +// +// # Installation +// +// go install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest +// +// # Usage +// +// Command-line conversion: +// +// relspec convert --from dbml --from-path schema.dbml \ +// --to gorm --to-path ./models +// +// Interactive editor: +// +// relspec edit --from pgsql --from-conn "postgres://..." \ +// --to dbml --to-path schema.dbml +// +// Schema comparison: +// +// relspec diff --source-type pgsql --source-conn "postgres://..." \ +// --target-type dbml --target-path schema.dbml +// +// Merge schemas: +// +// relspec merge --target schema1.dbml --sources schema2.dbml,schema3.dbml +// +// # Supported Formats +// +// Input/Output Formats: +// - dbml: Database Markup Language +// - dctx: DCTX schema files +// - drawdb: DrawDB JSON format +// - graphql: GraphQL schema definition +// - json: JSON schema representation +// - yaml: YAML schema representation +// - gorm: Go GORM models +// - bun: Go Bun models +// - drizzle: TypeScript Drizzle ORM +// - prisma: Prisma schema language +// - typeorm: TypeScript TypeORM entities +// - pgsql: PostgreSQL (live DB or SQL) +// - sqlite: SQLite (database file or SQL) +// +// # Library Usage +// +// RelSpec can be used as a Go library: +// +// import ( +// "git.warky.dev/wdevs/relspecgo/pkg/models" +// "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml" +// "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm" +// ) +// +// // Read DBML +// reader := dbml.NewReader(&readers.ReaderOptions{ +// FilePath: "schema.dbml", +// }) +// db, err := reader.ReadDatabase() +// +// // Write GORM models +// writer := gorm.NewWriter(&writers.WriterOptions{ +// OutputPath: "./models", +// PackageName: "models", +// }) +// err = writer.WriteDatabase(db) +// +// # Documentation +// +// Full documentation available at: https://git.warky.dev/wdevs/relspecgo +// +// API documentation: go doc git.warky.dev/wdevs/relspecgo/... +// +// # License +// +// See LICENSE file in the repository root. +package relspecgo diff --git a/go.mod b/go.mod index 4dd1414..d6e2422 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/uptrace/bun v1.2.16 golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.44.3 ) require ( @@ -43,5 +44,4 @@ require ( modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.44.3 // indirect ) diff --git a/go.sum b/go.sum index 523c4ec..e0dbfed 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,12 @@ github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeekl github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -87,6 +91,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -102,8 +108,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -146,6 +152,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -153,11 +161,31 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pkg/commontypes/doc.go b/pkg/commontypes/doc.go new file mode 100644 index 0000000..de48f32 --- /dev/null +++ b/pkg/commontypes/doc.go @@ -0,0 +1,28 @@ +// Package commontypes provides shared type definitions used across multiple packages. +// +// # Overview +// +// The commontypes package contains common data structures, constants, and type +// definitions that are shared between different parts of RelSpec but don't belong +// to the core models package. +// +// # Purpose +// +// This package helps avoid circular dependencies by providing a common location +// for types that are used by multiple packages without creating import cycles. +// +// # Contents +// +// Common types may include: +// - Shared enums and constants +// - Utility type aliases +// - Common error types +// - Shared configuration structures +// +// # Usage +// +// import "git.warky.dev/wdevs/relspecgo/pkg/commontypes" +// +// // Use common types +// var formatType commontypes.FormatType +package commontypes diff --git a/pkg/diff/doc.go b/pkg/diff/doc.go new file mode 100644 index 0000000..5886475 --- /dev/null +++ b/pkg/diff/doc.go @@ -0,0 +1,43 @@ +// Package diff provides utilities for comparing database schemas and identifying differences. +// +// # Overview +// +// The diff package compares two database models at various granularity levels (database, +// schema, table, column) and produces detailed reports of differences including: +// - Missing items (present in source but not in target) +// - Extra items (present in target but not in source) +// - Modified items (present in both but with different properties) +// +// # Usage +// +// Compare two databases and format the output: +// +// result := diff.CompareDatabases(sourceDB, targetDB) +// err := diff.FormatDiff(result, diff.OutputFormatText, os.Stdout) +// +// # Output Formats +// +// The package supports multiple output formats: +// - OutputFormatText: Human-readable text format +// - OutputFormatJSON: Structured JSON output +// - OutputFormatYAML: Structured YAML output +// +// # Comparison Scope +// +// The comparison covers: +// - Schemas: Name, description, and contents +// - Tables: Name, description, and all sub-elements +// - Columns: Type, nullability, defaults, constraints +// - Indexes: Columns, uniqueness, type +// - Constraints: Type, columns, references +// - Relationships: Type, from/to tables and columns +// - Views: Definition and columns +// - Sequences: Start value, increment, min/max values +// +// # Use Cases +// +// - Schema migration planning +// - Database synchronization verification +// - Change tracking and auditing +// - CI/CD pipeline validation +package diff diff --git a/pkg/inspector/doc.go b/pkg/inspector/doc.go new file mode 100644 index 0000000..9fb9fea --- /dev/null +++ b/pkg/inspector/doc.go @@ -0,0 +1,40 @@ +// Package inspector provides database introspection capabilities for live databases. +// +// # Overview +// +// The inspector package contains utilities for connecting to live databases and +// extracting their schema information through system catalog queries and metadata +// inspection. +// +// # Features +// +// - Database connection management +// - Schema metadata extraction +// - Table structure analysis +// - Constraint and index discovery +// - Foreign key relationship mapping +// +// # Supported Databases +// +// - PostgreSQL (via pgx driver) +// - SQLite (via modernc.org/sqlite driver) +// +// # Usage +// +// This package is used internally by database readers (pgsql, sqlite) to perform +// live schema introspection: +// +// inspector := inspector.NewPostgreSQLInspector(connString) +// schemas, err := inspector.GetSchemas() +// tables, err := inspector.GetTables(schemaName) +// +// # Architecture +// +// Each database type has its own inspector implementation that understands the +// specific system catalogs and metadata structures of that database system. +// +// # Security +// +// Inspectors use read-only operations and never modify database structure. +// Connection credentials should be handled securely. +package inspector diff --git a/pkg/pgsql/doc.go b/pkg/pgsql/doc.go new file mode 100644 index 0000000..f5c5f50 --- /dev/null +++ b/pkg/pgsql/doc.go @@ -0,0 +1,36 @@ +// Package pgsql provides PostgreSQL-specific utilities and helpers. +// +// # Overview +// +// The pgsql package contains PostgreSQL-specific functionality including: +// - SQL reserved keyword validation +// - Data type mappings and conversions +// - PostgreSQL-specific schema introspection helpers +// +// # Components +// +// keywords.go - SQL reserved keywords validation +// +// Provides functions to check if identifiers conflict with SQL reserved words +// and need quoting for safe usage in PostgreSQL queries. +// +// datatypes.go - PostgreSQL data type utilities +// +// Contains mappings between PostgreSQL data types and their equivalents in other +// systems, as well as type conversion and normalization functions. +// +// # Usage +// +// // Check if identifier needs quoting +// if pgsql.IsReservedKeyword("user") { +// // Quote the identifier +// } +// +// // Normalize data type +// normalizedType := pgsql.NormalizeDataType("varchar(255)") +// +// # Purpose +// +// This package supports the PostgreSQL reader and writer implementations by providing +// shared utilities for handling PostgreSQL-specific schema elements and constraints. +package pgsql diff --git a/pkg/readers/doc.go b/pkg/readers/doc.go new file mode 100644 index 0000000..1fd9f14 --- /dev/null +++ b/pkg/readers/doc.go @@ -0,0 +1,53 @@ +// Package readers provides interfaces and implementations for reading database schemas +// from various input formats and data sources. +// +// # Overview +// +// The readers package defines a common Reader interface that all format-specific readers +// implement. This allows RelSpec to read database schemas from multiple sources including: +// - Live databases (PostgreSQL, SQLite) +// - Schema definition files (DBML, DCTX, DrawDB, GraphQL) +// - ORM model files (GORM, Bun, Drizzle, Prisma, TypeORM) +// - Data interchange formats (JSON, YAML) +// +// # Architecture +// +// Each reader implementation is located in its own subpackage (e.g., pkg/readers/dbml, +// pkg/readers/pgsql) and implements the Reader interface, supporting three levels of +// granularity: +// - ReadDatabase() - Read complete database with all schemas +// - ReadSchema() - Read single schema with all tables +// - ReadTable() - Read single table with all columns and metadata +// +// # Usage +// +// Readers are instantiated with ReaderOptions containing source-specific configuration: +// +// // Read from file +// reader := dbml.NewReader(&readers.ReaderOptions{ +// FilePath: "schema.dbml", +// }) +// db, err := reader.ReadDatabase() +// +// // Read from database +// reader := pgsql.NewReader(&readers.ReaderOptions{ +// ConnectionString: "postgres://user:pass@localhost/mydb", +// }) +// db, err := reader.ReadDatabase() +// +// # Supported Formats +// +// - dbml: Database Markup Language files +// - dctx: DCTX schema files +// - drawdb: DrawDB JSON format +// - graphql: GraphQL schema definition language +// - json: JSON database schema +// - yaml: YAML database schema +// - gorm: Go GORM model structs +// - bun: Go Bun model structs +// - drizzle: TypeScript Drizzle ORM schemas +// - prisma: Prisma schema language +// - typeorm: TypeScript TypeORM entities +// - pgsql: PostgreSQL live database introspection +// - sqlite: SQLite database files +package readers diff --git a/pkg/readers/sqlite/queries.go b/pkg/readers/sqlite/queries.go index 4744133..c1bd273 100644 --- a/pkg/readers/sqlite/queries.go +++ b/pkg/readers/sqlite/queries.go @@ -106,7 +106,7 @@ func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, erro } // Check for autoincrement (SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT) - if pk > 0 && strings.ToUpper(dataType) == "INTEGER" { + if pk > 0 && strings.EqualFold(dataType, "INTEGER") { column.AutoIncrement = r.isAutoIncrement(tableName, name) } diff --git a/pkg/readers/sqlite/reader.go b/pkg/readers/sqlite/reader.go index 428e6ce..5228185 100644 --- a/pkg/readers/sqlite/reader.go +++ b/pkg/readers/sqlite/reader.go @@ -187,35 +187,35 @@ func (r *Reader) close() { func (r *Reader) mapDataType(sqliteType string) string { // SQLite has a flexible type system, but we map common types typeMap := map[string]string{ - "INTEGER": "int", - "INT": "int", - "TINYINT": "int8", - "SMALLINT": "int16", - "MEDIUMINT": "int", - "BIGINT": "int64", - "UNSIGNED BIG INT": "uint64", - "INT2": "int16", - "INT8": "int64", - "REAL": "float64", - "DOUBLE": "float64", - "DOUBLE PRECISION": "float64", - "FLOAT": "float32", - "NUMERIC": "decimal", - "DECIMAL": "decimal", - "BOOLEAN": "bool", - "BOOL": "bool", - "DATE": "date", - "DATETIME": "timestamp", - "TIMESTAMP": "timestamp", - "TEXT": "string", - "VARCHAR": "string", - "CHAR": "string", - "CHARACTER": "string", + "INTEGER": "int", + "INT": "int", + "TINYINT": "int8", + "SMALLINT": "int16", + "MEDIUMINT": "int", + "BIGINT": "int64", + "UNSIGNED BIG INT": "uint64", + "INT2": "int16", + "INT8": "int64", + "REAL": "float64", + "DOUBLE": "float64", + "DOUBLE PRECISION": "float64", + "FLOAT": "float32", + "NUMERIC": "decimal", + "DECIMAL": "decimal", + "BOOLEAN": "bool", + "BOOL": "bool", + "DATE": "date", + "DATETIME": "timestamp", + "TIMESTAMP": "timestamp", + "TEXT": "string", + "VARCHAR": "string", + "CHAR": "string", + "CHARACTER": "string", "VARYING CHARACTER": "string", - "NCHAR": "string", - "NVARCHAR": "string", - "CLOB": "text", - "BLOB": "bytea", + "NCHAR": "string", + "NVARCHAR": "string", + "CLOB": "text", + "BLOB": "bytea", } // Try exact match first diff --git a/pkg/reflectutil/doc.go b/pkg/reflectutil/doc.go new file mode 100644 index 0000000..7442e9e --- /dev/null +++ b/pkg/reflectutil/doc.go @@ -0,0 +1,36 @@ +// Package reflectutil provides reflection utilities for analyzing Go code structures. +// +// # Overview +// +// The reflectutil package offers helper functions for working with Go's reflection +// capabilities, particularly for parsing Go struct definitions and extracting type +// information. This is used by readers that parse ORM model files. +// +// # Features +// +// - Struct tag parsing and extraction +// - Type information analysis +// - Field metadata extraction +// - ORM tag interpretation (GORM, Bun, etc.) +// +// # Usage +// +// This package is primarily used internally by readers like GORM and Bun to parse +// Go struct definitions and convert them to database schema models. +// +// // Example: Parse struct tags +// tags := reflectutil.ParseStructTags(field) +// columnName := tags.Get("db") +// +// # Supported ORM Tags +// +// The package understands tag conventions from: +// - GORM (gorm tag) +// - Bun (bun tag) +// - Standard database/sql (db tag) +// +// # Purpose +// +// This package enables RelSpec to read existing ORM models and convert them to +// a unified schema representation for transformation to other formats. +package reflectutil diff --git a/pkg/transform/doc.go b/pkg/transform/doc.go new file mode 100644 index 0000000..22d6852 --- /dev/null +++ b/pkg/transform/doc.go @@ -0,0 +1,34 @@ +// Package transform provides validation and transformation utilities for database models. +// +// # Overview +// +// The transform package contains a Transformer type that provides methods for validating +// and normalizing database schemas. It ensures schema correctness and consistency across +// different format conversions. +// +// # Features +// +// - Database validation (structure and naming conventions) +// - Schema validation (completeness and integrity) +// - Table validation (column definitions and constraints) +// - Data type normalization +// +// # Usage +// +// transformer := transform.NewTransformer() +// err := transformer.ValidateDatabase(db) +// if err != nil { +// log.Fatal("Invalid database schema:", err) +// } +// +// # Validation Scope +// +// The transformer validates: +// - Required fields presence +// - Naming convention adherence +// - Data type compatibility +// - Constraint consistency +// - Relationship integrity +// +// Note: Some validation methods are currently stubs and will be implemented as needed. +package transform diff --git a/pkg/ui/doc.go b/pkg/ui/doc.go new file mode 100644 index 0000000..918c5af --- /dev/null +++ b/pkg/ui/doc.go @@ -0,0 +1,57 @@ +// Package ui provides an interactive terminal user interface (TUI) for editing database schemas. +// +// # Overview +// +// The ui package implements a full-featured terminal-based schema editor using tview, +// allowing users to visually create, modify, and manage database schemas without writing +// code or SQL. +// +// # Features +// +// The schema editor supports: +// - Database management: Edit name, description, and properties +// - Schema management: Create, edit, delete schemas +// - Table management: Create, edit, delete tables +// - Column management: Add, modify, delete columns with full property support +// - Relationship management: Define and edit table relationships +// - Domain management: Organize tables into logical domains +// - Import & merge: Combine schemas from multiple sources +// - Save: Export to any supported format +// +// # Architecture +// +// The package is organized into several components: +// - editor.go: Main editor and application lifecycle +// - *_screens.go: UI screens for each entity type +// - *_dataops.go: Business logic and data operations +// - dialogs.go: Reusable dialog components +// - load_save_screens.go: File I/O and format selection +// - main_menu.go: Primary navigation menu +// +// # Usage +// +// editor := ui.NewSchemaEditor(database) +// if err := editor.Run(); err != nil { +// log.Fatal(err) +// } +// +// Or with pre-configured load/save options: +// +// editor := ui.NewSchemaEditorWithConfigs(database, loadConfig, saveConfig) +// if err := editor.Run(); err != nil { +// log.Fatal(err) +// } +// +// # Navigation +// +// - Arrow keys: Navigate between items +// - Enter: Select/edit item +// - Tab/Shift+Tab: Navigate between buttons +// - Escape: Go back/cancel +// - Letter shortcuts: Quick actions (e.g., 'n' for new, 'e' for edit, 'd' for delete) +// +// # Integration +// +// The editor integrates with all readers and writers, supporting load/save operations +// for any format supported by RelSpec (DBML, PostgreSQL, GORM, Prisma, etc.). +package ui diff --git a/pkg/ui/relation_dataops.go b/pkg/ui/relation_dataops.go new file mode 100644 index 0000000..30ebd38 --- /dev/null +++ b/pkg/ui/relation_dataops.go @@ -0,0 +1,115 @@ +package ui + +import "git.warky.dev/wdevs/relspecgo/pkg/models" + +// Relationship data operations - business logic for relationship management + +// CreateRelationship creates a new relationship and adds it to a table +func (se *SchemaEditor) CreateRelationship(schemaIndex, tableIndex int, rel *models.Relationship) *models.Relationship { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return nil + } + + schema := se.db.Schemas[schemaIndex] + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return nil + } + + table := schema.Tables[tableIndex] + if table.Relationships == nil { + table.Relationships = make(map[string]*models.Relationship) + } + + table.Relationships[rel.Name] = rel + table.UpdateDate() + return rel +} + +// UpdateRelationship updates an existing relationship +func (se *SchemaEditor) UpdateRelationship(schemaIndex, tableIndex int, oldName string, rel *models.Relationship) bool { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return false + } + + schema := se.db.Schemas[schemaIndex] + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return false + } + + table := schema.Tables[tableIndex] + if table.Relationships == nil { + return false + } + + // Delete old entry if name changed + if oldName != rel.Name { + delete(table.Relationships, oldName) + } + + table.Relationships[rel.Name] = rel + table.UpdateDate() + return true +} + +// DeleteRelationship removes a relationship from a table +func (se *SchemaEditor) DeleteRelationship(schemaIndex, tableIndex int, relName string) bool { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return false + } + + schema := se.db.Schemas[schemaIndex] + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return false + } + + table := schema.Tables[tableIndex] + if table.Relationships == nil { + return false + } + + delete(table.Relationships, relName) + table.UpdateDate() + return true +} + +// GetRelationship returns a relationship by name +func (se *SchemaEditor) GetRelationship(schemaIndex, tableIndex int, relName string) *models.Relationship { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return nil + } + + schema := se.db.Schemas[schemaIndex] + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return nil + } + + table := schema.Tables[tableIndex] + if table.Relationships == nil { + return nil + } + + return table.Relationships[relName] +} + +// GetRelationshipNames returns all relationship names for a table +func (se *SchemaEditor) GetRelationshipNames(schemaIndex, tableIndex int) []string { + if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) { + return nil + } + + schema := se.db.Schemas[schemaIndex] + if tableIndex < 0 || tableIndex >= len(schema.Tables) { + return nil + } + + table := schema.Tables[tableIndex] + if table.Relationships == nil { + return nil + } + + names := make([]string, 0, len(table.Relationships)) + for name := range table.Relationships { + names = append(names, name) + } + return names +} diff --git a/pkg/ui/relation_screens.go b/pkg/ui/relation_screens.go new file mode 100644 index 0000000..3128c8b --- /dev/null +++ b/pkg/ui/relation_screens.go @@ -0,0 +1,486 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +// showRelationshipList displays all relationships for a table +func (se *SchemaEditor) showRelationshipList(schemaIndex, tableIndex int) { + table := se.GetTable(schemaIndex, tableIndex) + if table == nil { + return + } + + flex := tview.NewFlex().SetDirection(tview.FlexRow) + + // Title + title := tview.NewTextView(). + SetText(fmt.Sprintf("[::b]Relationships for Table: %s", table.Name)). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + + // Create relationships table + relTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0) + + // Add header row + headers := []string{"Name", "Type", "From Columns", "To Table", "To Columns", "Description"} + headerWidths := []int{20, 15, 20, 20, 20} + for i, header := range headers { + padding := "" + if i < len(headerWidths) { + padding = strings.Repeat(" ", headerWidths[i]-len(header)) + } + cell := tview.NewTableCell(header + padding). + SetTextColor(tcell.ColorYellow). + SetSelectable(false). + SetAlign(tview.AlignLeft) + relTable.SetCell(0, i, cell) + } + + // Get relationship names + relNames := se.GetRelationshipNames(schemaIndex, tableIndex) + for row, relName := range relNames { + rel := table.Relationships[relName] + + // Name + nameStr := fmt.Sprintf("%-20s", rel.Name) + nameCell := tview.NewTableCell(nameStr).SetSelectable(true) + relTable.SetCell(row+1, 0, nameCell) + + // Type + typeStr := fmt.Sprintf("%-15s", string(rel.Type)) + typeCell := tview.NewTableCell(typeStr).SetSelectable(true) + relTable.SetCell(row+1, 1, typeCell) + + // From Columns + fromColsStr := strings.Join(rel.FromColumns, ", ") + fromColsStr = fmt.Sprintf("%-20s", fromColsStr) + fromColsCell := tview.NewTableCell(fromColsStr).SetSelectable(true) + relTable.SetCell(row+1, 2, fromColsCell) + + // To Table + toTableStr := rel.ToTable + if rel.ToSchema != "" && rel.ToSchema != table.Schema { + toTableStr = rel.ToSchema + "." + rel.ToTable + } + toTableStr = fmt.Sprintf("%-20s", toTableStr) + toTableCell := tview.NewTableCell(toTableStr).SetSelectable(true) + relTable.SetCell(row+1, 3, toTableCell) + + // To Columns + toColsStr := strings.Join(rel.ToColumns, ", ") + toColsStr = fmt.Sprintf("%-20s", toColsStr) + toColsCell := tview.NewTableCell(toColsStr).SetSelectable(true) + relTable.SetCell(row+1, 4, toColsCell) + + // Description + descCell := tview.NewTableCell(rel.Description).SetSelectable(true) + relTable.SetCell(row+1, 5, descCell) + } + + relTable.SetTitle(" Relationships ").SetBorder(true).SetTitleAlign(tview.AlignLeft) + + // Action buttons + btnFlex := tview.NewFlex() + btnNew := tview.NewButton("New Relationship [n]").SetSelectedFunc(func() { + se.showNewRelationshipDialog(schemaIndex, tableIndex) + }) + btnEdit := tview.NewButton("Edit [e]").SetSelectedFunc(func() { + row, _ := relTable.GetSelection() + if row > 0 && row <= len(relNames) { + relName := relNames[row-1] + se.showEditRelationshipDialog(schemaIndex, tableIndex, relName) + } + }) + btnDelete := tview.NewButton("Delete [d]").SetSelectedFunc(func() { + row, _ := relTable.GetSelection() + if row > 0 && row <= len(relNames) { + relName := relNames[row-1] + se.showDeleteRelationshipConfirm(schemaIndex, tableIndex, relName) + } + }) + btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() { + se.pages.RemovePage("relationships") + se.pages.SwitchToPage("table-editor") + }) + + // Set up button navigation + btnNew.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyBacktab { + se.app.SetFocus(relTable) + return nil + } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(btnEdit) + return nil + } + return event + }) + + btnEdit.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyBacktab { + se.app.SetFocus(btnNew) + return nil + } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(btnDelete) + return nil + } + return event + }) + + btnDelete.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyBacktab { + se.app.SetFocus(btnEdit) + return nil + } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(btnBack) + return nil + } + return event + }) + + btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyBacktab { + se.app.SetFocus(btnDelete) + return nil + } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(relTable) + return nil + } + return event + }) + + btnFlex.AddItem(btnNew, 0, 1, true). + AddItem(btnEdit, 0, 1, false). + AddItem(btnDelete, 0, 1, false). + AddItem(btnBack, 0, 1, false) + + relTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage("relationships") + se.pages.SwitchToPage("table-editor") + return nil + } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(btnNew) + return nil + } + if event.Key() == tcell.KeyEnter { + row, _ := relTable.GetSelection() + if row > 0 && row <= len(relNames) { + relName := relNames[row-1] + se.showEditRelationshipDialog(schemaIndex, tableIndex, relName) + } + return nil + } + if event.Rune() == 'n' { + se.showNewRelationshipDialog(schemaIndex, tableIndex) + return nil + } + if event.Rune() == 'e' { + row, _ := relTable.GetSelection() + if row > 0 && row <= len(relNames) { + relName := relNames[row-1] + se.showEditRelationshipDialog(schemaIndex, tableIndex, relName) + } + return nil + } + if event.Rune() == 'd' { + row, _ := relTable.GetSelection() + if row > 0 && row <= len(relNames) { + relName := relNames[row-1] + se.showDeleteRelationshipConfirm(schemaIndex, tableIndex, relName) + } + return nil + } + if event.Rune() == 'b' { + se.pages.RemovePage("relationships") + se.pages.SwitchToPage("table-editor") + return nil + } + return event + }) + + flex.AddItem(title, 1, 0, false). + AddItem(relTable, 0, 1, true). + AddItem(btnFlex, 1, 0, false) + + se.pages.AddPage("relationships", flex, true, true) +} + +// showNewRelationshipDialog shows dialog to create a new relationship +func (se *SchemaEditor) showNewRelationshipDialog(schemaIndex, tableIndex int) { + table := se.GetTable(schemaIndex, tableIndex) + if table == nil { + return + } + + form := tview.NewForm() + + // Collect all tables for dropdown + var allTables []string + var tableMap []struct{ schemaIdx, tableIdx int } + for si, schema := range se.db.Schemas { + for ti, t := range schema.Tables { + tableName := t.Name + if schema.Name != table.Schema { + tableName = schema.Name + "." + t.Name + } + allTables = append(allTables, tableName) + tableMap = append(tableMap, struct{ schemaIdx, tableIdx int }{si, ti}) + } + } + + relName := "" + relType := models.OneToMany + fromColumns := "" + toColumns := "" + description := "" + selectedTableIdx := 0 + + form.AddInputField("Name", "", 40, nil, func(value string) { + relName = value + }) + + form.AddDropDown("Type", []string{ + string(models.OneToOne), + string(models.OneToMany), + string(models.ManyToMany), + }, 1, func(option string, optionIndex int) { + relType = models.RelationType(option) + }) + + form.AddInputField("From Columns (comma-separated)", "", 40, nil, func(value string) { + fromColumns = value + }) + + form.AddDropDown("To Table", allTables, 0, func(option string, optionIndex int) { + selectedTableIdx = optionIndex + }) + + form.AddInputField("To Columns (comma-separated)", "", 40, nil, func(value string) { + toColumns = value + }) + + form.AddInputField("Description", "", 60, nil, func(value string) { + description = value + }) + + form.AddButton("Save", func() { + if relName == "" { + return + } + + // Parse columns + fromCols := strings.Split(fromColumns, ",") + for i := range fromCols { + fromCols[i] = strings.TrimSpace(fromCols[i]) + } + + toCols := strings.Split(toColumns, ",") + for i := range toCols { + toCols[i] = strings.TrimSpace(toCols[i]) + } + + // Get target table + targetSchema := se.db.Schemas[tableMap[selectedTableIdx].schemaIdx] + targetTable := targetSchema.Tables[tableMap[selectedTableIdx].tableIdx] + + rel := models.InitRelationship(relName, relType) + rel.FromTable = table.Name + rel.FromSchema = table.Schema + rel.FromColumns = fromCols + rel.ToTable = targetTable.Name + rel.ToSchema = targetTable.Schema + rel.ToColumns = toCols + rel.Description = description + + se.CreateRelationship(schemaIndex, tableIndex, rel) + + se.pages.RemovePage("new-relationship") + se.pages.RemovePage("relationships") + se.showRelationshipList(schemaIndex, tableIndex) + }) + + form.AddButton("Back", func() { + se.pages.RemovePage("new-relationship") + }) + + form.SetBorder(true).SetTitle(" New Relationship ").SetTitleAlign(tview.AlignLeft) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage("new-relationship") + return nil + } + return event + }) + + se.pages.AddPage("new-relationship", form, true, true) +} + +// showEditRelationshipDialog shows dialog to edit a relationship +func (se *SchemaEditor) showEditRelationshipDialog(schemaIndex, tableIndex int, relName string) { + table := se.GetTable(schemaIndex, tableIndex) + if table == nil { + return + } + + rel := se.GetRelationship(schemaIndex, tableIndex, relName) + if rel == nil { + return + } + + form := tview.NewForm() + + // Collect all tables for dropdown + var allTables []string + var tableMap []struct{ schemaIdx, tableIdx int } + selectedTableIdx := 0 + for si, schema := range se.db.Schemas { + for ti, t := range schema.Tables { + tableName := t.Name + if schema.Name != table.Schema { + tableName = schema.Name + "." + t.Name + } + allTables = append(allTables, tableName) + tableMap = append(tableMap, struct{ schemaIdx, tableIdx int }{si, ti}) + + // Check if this is the current target table + if t.Name == rel.ToTable && schema.Name == rel.ToSchema { + selectedTableIdx = len(allTables) - 1 + } + } + } + + newName := rel.Name + relType := rel.Type + fromColumns := strings.Join(rel.FromColumns, ", ") + toColumns := strings.Join(rel.ToColumns, ", ") + description := rel.Description + + form.AddInputField("Name", rel.Name, 40, nil, func(value string) { + newName = value + }) + + // Find initial type index + typeIdx := 1 // OneToMany default + typeOptions := []string{ + string(models.OneToOne), + string(models.OneToMany), + string(models.ManyToMany), + } + for i, opt := range typeOptions { + if opt == string(rel.Type) { + typeIdx = i + break + } + } + + form.AddDropDown("Type", typeOptions, typeIdx, func(option string, optionIndex int) { + relType = models.RelationType(option) + }) + + form.AddInputField("From Columns (comma-separated)", fromColumns, 40, nil, func(value string) { + fromColumns = value + }) + + form.AddDropDown("To Table", allTables, selectedTableIdx, func(option string, optionIndex int) { + selectedTableIdx = optionIndex + }) + + form.AddInputField("To Columns (comma-separated)", toColumns, 40, nil, func(value string) { + toColumns = value + }) + + form.AddInputField("Description", rel.Description, 60, nil, func(value string) { + description = value + }) + + form.AddButton("Save", func() { + if newName == "" { + return + } + + // Parse columns + fromCols := strings.Split(fromColumns, ",") + for i := range fromCols { + fromCols[i] = strings.TrimSpace(fromCols[i]) + } + + toCols := strings.Split(toColumns, ",") + for i := range toCols { + toCols[i] = strings.TrimSpace(toCols[i]) + } + + // Get target table + targetSchema := se.db.Schemas[tableMap[selectedTableIdx].schemaIdx] + targetTable := targetSchema.Tables[tableMap[selectedTableIdx].tableIdx] + + updatedRel := models.InitRelationship(newName, relType) + updatedRel.FromTable = table.Name + updatedRel.FromSchema = table.Schema + updatedRel.FromColumns = fromCols + updatedRel.ToTable = targetTable.Name + updatedRel.ToSchema = targetTable.Schema + updatedRel.ToColumns = toCols + updatedRel.Description = description + updatedRel.GUID = rel.GUID + + se.UpdateRelationship(schemaIndex, tableIndex, relName, updatedRel) + + se.pages.RemovePage("edit-relationship") + se.pages.RemovePage("relationships") + se.showRelationshipList(schemaIndex, tableIndex) + }) + + form.AddButton("Back", func() { + se.pages.RemovePage("edit-relationship") + }) + + form.SetBorder(true).SetTitle(" Edit Relationship ").SetTitleAlign(tview.AlignLeft) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage("edit-relationship") + return nil + } + return event + }) + + se.pages.AddPage("edit-relationship", form, true, true) +} + +// showDeleteRelationshipConfirm shows confirmation dialog for deleting a relationship +func (se *SchemaEditor) showDeleteRelationshipConfirm(schemaIndex, tableIndex int, relName string) { + modal := tview.NewModal(). + SetText(fmt.Sprintf("Delete relationship '%s'? This action cannot be undone.", relName)). + AddButtons([]string{"Cancel", "Delete"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Delete" { + se.DeleteRelationship(schemaIndex, tableIndex, relName) + se.pages.RemovePage("delete-relationship-confirm") + se.pages.RemovePage("relationships") + se.showRelationshipList(schemaIndex, tableIndex) + } else { + se.pages.RemovePage("delete-relationship-confirm") + } + }) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + se.pages.RemovePage("delete-relationship-confirm") + return nil + } + return event + }) + + se.pages.AddAndSwitchToPage("delete-relationship-confirm", modal, true) +} diff --git a/pkg/ui/table_screens.go b/pkg/ui/table_screens.go index ba164d1..4a84821 100644 --- a/pkg/ui/table_screens.go +++ b/pkg/ui/table_screens.go @@ -270,6 +270,9 @@ func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *mode se.showColumnEditor(schemaIndex, tableIndex, row-1, column) } }) + btnRelations := tview.NewButton("Relations [r]").SetSelectedFunc(func() { + se.showRelationshipList(schemaIndex, tableIndex) + }) btnDelTable := tview.NewButton("Delete Table [d]").SetSelectedFunc(func() { se.showDeleteTableConfirm(schemaIndex, tableIndex) }) @@ -308,6 +311,18 @@ func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *mode se.app.SetFocus(btnEditColumn) return nil } + if event.Key() == tcell.KeyTab { + se.app.SetFocus(btnRelations) + return nil + } + return event + }) + + btnRelations.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyBacktab { + se.app.SetFocus(btnEditTable) + return nil + } if event.Key() == tcell.KeyTab { se.app.SetFocus(btnDelTable) return nil @@ -317,7 +332,7 @@ func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *mode btnDelTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyBacktab { - se.app.SetFocus(btnEditTable) + se.app.SetFocus(btnRelations) return nil } if event.Key() == tcell.KeyTab { @@ -342,6 +357,7 @@ func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *mode btnFlex.AddItem(btnNewCol, 0, 1, true). AddItem(btnEditColumn, 0, 1, false). AddItem(btnEditTable, 0, 1, false). + AddItem(btnRelations, 0, 1, false). AddItem(btnDelTable, 0, 1, false). AddItem(btnBack, 0, 1, false) @@ -373,6 +389,10 @@ func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *mode } return nil } + if event.Rune() == 'r' { + se.showRelationshipList(schemaIndex, tableIndex) + return nil + } if event.Rune() == 'b' { se.pages.RemovePage("table-editor") se.pages.SwitchToPage("schema-editor") diff --git a/pkg/writers/doc.go b/pkg/writers/doc.go new file mode 100644 index 0000000..f5cff50 --- /dev/null +++ b/pkg/writers/doc.go @@ -0,0 +1,67 @@ +// Package writers provides interfaces and implementations for writing database schemas +// to various output formats and destinations. +// +// # Overview +// +// The writers package defines a common Writer interface that all format-specific writers +// implement. This allows RelSpec to export database schemas to multiple formats including: +// - SQL schema files (PostgreSQL, SQLite) +// - Schema definition files (DBML, DCTX, DrawDB, GraphQL) +// - ORM model files (GORM, Bun, Drizzle, Prisma, TypeORM) +// - Data interchange formats (JSON, YAML) +// +// # Architecture +// +// Each writer implementation is located in its own subpackage (e.g., pkg/writers/dbml, +// pkg/writers/pgsql) and implements the Writer interface, supporting three levels of +// granularity: +// - WriteDatabase() - Write complete database with all schemas +// - WriteSchema() - Write single schema with all tables +// - WriteTable() - Write single table with all columns and metadata +// +// # Usage +// +// Writers are instantiated with WriterOptions containing destination-specific configuration: +// +// // Write to file +// writer := dbml.NewWriter(&writers.WriterOptions{ +// OutputPath: "schema.dbml", +// }) +// err := writer.WriteDatabase(db) +// +// // Write ORM models with package name +// writer := gorm.NewWriter(&writers.WriterOptions{ +// OutputPath: "./models", +// PackageName: "models", +// }) +// err := writer.WriteDatabase(db) +// +// // Write with schema flattening for SQLite +// writer := sqlite.NewWriter(&writers.WriterOptions{ +// OutputPath: "schema.sql", +// FlattenSchema: true, +// }) +// err := writer.WriteDatabase(db) +// +// # Schema Flattening +// +// The FlattenSchema option controls how schema-qualified table names are handled: +// - false (default): Uses dot notation (schema.table) +// - true: Joins with underscore (schema_table), useful for SQLite +// +// # Supported Formats +// +// - dbml: Database Markup Language files +// - dctx: DCTX schema files +// - drawdb: DrawDB JSON format +// - graphql: GraphQL schema definition language +// - json: JSON database schema +// - yaml: YAML database schema +// - gorm: Go GORM model structs +// - bun: Go Bun model structs +// - drizzle: TypeScript Drizzle ORM schemas +// - prisma: Prisma schema language +// - typeorm: TypeScript TypeORM entities +// - pgsql: PostgreSQL SQL schema +// - sqlite: SQLite SQL schema with automatic flattening +package writers