Compare commits
27 Commits
5d3c86119e
...
v1.0.22
| Author | SHA1 | Date | |
|---|---|---|---|
| cafe6a461f | |||
| abdb9b4c78 | |||
| e7a15c8e4f | |||
| c36b5ede2b | |||
| 51ab29f8e3 | |||
| f532fc110c | |||
| 92dff99725 | |||
| 283b568adb | |||
| 122743ee43 | |||
| 91b6046b9b | |||
| 6f55505444 | |||
| e0e7b64c69 | |||
| 4181cb1fbd | |||
| 120ffc6a5a | |||
| b20ad35485 | |||
| f258f8baeb | |||
| 6388daba56 | |||
| f6c3f2b460 | |||
| 156e655571 | |||
| b57e1ba304 | |||
| 19fba62f1b | |||
| b4ff4334cc | |||
| 5d9b00c8f2 | |||
| debf351c48 | |||
| d87d657275 | |||
| 1795eb64d1 | |||
| 355f0f918f |
@@ -4,10 +4,7 @@
|
||||
"description": "Database Relations Specification Tool for Go",
|
||||
"language": "go"
|
||||
},
|
||||
"agent": {
|
||||
"preferred": "Explore",
|
||||
"description": "Use Explore agent for fast codebase navigation and Go project exploration"
|
||||
},
|
||||
|
||||
"codeStyle": {
|
||||
"useGofmt": true,
|
||||
"lineLength": 100,
|
||||
|
||||
5
.github/workflows/integration-tests.yml
vendored
5
.github/workflows/integration-tests.yml
vendored
@@ -46,6 +46,11 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Install PostgreSQL client
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
|
||||
- name: Initialize test database
|
||||
env:
|
||||
PGPASSWORD: relspec_test_password
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ dist/
|
||||
build/
|
||||
bin/
|
||||
tests/integration/failed_statements_example.txt
|
||||
test_output.log
|
||||
|
||||
72
README.md
72
README.md
@@ -85,6 +85,29 @@ RelSpec includes a powerful schema validation and linting tool:
|
||||
## Use of AI
|
||||
[Rules and use of AI](./AI_USE.md)
|
||||
|
||||
## User Interface
|
||||
|
||||
RelSpec provides an interactive terminal-based user interface for managing and editing database schemas. The UI allows you to:
|
||||
|
||||
- **Browse Databases** - Navigate through your database structure with an intuitive menu system
|
||||
- **Edit Schemas** - Create, modify, and organize database schemas
|
||||
- **Manage Tables** - Add, update, or delete tables with full control over structure
|
||||
- **Configure Columns** - Define column properties, data types, constraints, and relationships
|
||||
- **Interactive Editing** - Real-time validation and feedback as you make changes
|
||||
|
||||
The interface supports multiple input formats, making it easy to load, edit, and save your database definitions in various formats.
|
||||
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/main_screen.jpg">
|
||||
</p>
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/table_view.jpg">
|
||||
</p>
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/edit_column.jpg">
|
||||
</p>
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -95,6 +118,55 @@ go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Schema Editor
|
||||
|
||||
```bash
|
||||
# Launch interactive editor with a DBML schema
|
||||
relspec edit --from dbml --from-path schema.dbml --to dbml --to-path schema.dbml
|
||||
|
||||
# Edit PostgreSQL database in place
|
||||
relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to pgsql --to-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Edit JSON schema and save as GORM models
|
||||
relspec edit --from json --from-path db.json --to gorm --to-path models/
|
||||
```
|
||||
|
||||
The `edit` command launches an interactive terminal user interface where you can:
|
||||
- Browse and navigate your database structure
|
||||
- Create, modify, and delete schemas, tables, and columns
|
||||
- Configure column properties, constraints, and relationships
|
||||
- Save changes to various formats
|
||||
- Import and merge schemas from other databases
|
||||
|
||||
### Schema Merging
|
||||
|
||||
```bash
|
||||
# Merge two JSON schemas (additive merge - adds missing items only)
|
||||
relspec merge --target json --target-path base.json \
|
||||
--source json --source-path additions.json \
|
||||
--output json --output-path merged.json
|
||||
|
||||
# Merge PostgreSQL database into JSON, skipping specific tables
|
||||
relspec merge --target json --target-path current.json \
|
||||
--source pgsql --source-conn "postgres://user:pass@localhost/source_db" \
|
||||
--output json --output-path updated.json \
|
||||
--skip-tables "audit_log,temp_tables"
|
||||
|
||||
# Cross-format merge (DBML + YAML → JSON)
|
||||
relspec merge --target dbml --target-path base.dbml \
|
||||
--source yaml --source-path additions.yaml \
|
||||
--output json --output-path result.json \
|
||||
--skip-relations --skip-views
|
||||
```
|
||||
|
||||
The `merge` command combines two database schemas additively:
|
||||
- Adds missing schemas, tables, columns, and other objects
|
||||
- Never modifies or deletes existing items (safe operation)
|
||||
- Supports selective merging with skip options (domains, relations, enums, views, sequences, specific tables)
|
||||
- Works across any combination of supported formats
|
||||
- Perfect for integrating multiple schema definitions or applying patches
|
||||
|
||||
### Schema Conversion
|
||||
|
||||
```bash
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -22,6 +22,17 @@
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
|
||||
## UI
|
||||
- [✔️] Basic UI (I went with tview)
|
||||
- [✔️] Save / Load Database
|
||||
- [✔️] Schemas / Domains / Tables
|
||||
- [ ] Add Relations
|
||||
- [ ] Add Indexes
|
||||
- [ ] Add Views
|
||||
- [ ] Add Sequences
|
||||
- [ ] Add Scripts
|
||||
- [ ] Domain / Table Assignment
|
||||
|
||||
## Documentation
|
||||
- [ ] API documentation (godoc)
|
||||
- [ ] Usage examples for each format combination
|
||||
|
||||
BIN
assets/image/screenshots/edit_column.jpg
Normal file
BIN
assets/image/screenshots/edit_column.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/image/screenshots/main_screen.jpg
Normal file
BIN
assets/image/screenshots/main_screen.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/image/screenshots/table_view.jpg
Normal file
BIN
assets/image/screenshots/table_view.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
334
cmd/relspec/edit.go
Normal file
334
cmd/relspec/edit.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/ui"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
wdctx "git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
wdrawdb "git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
wdrizzle "git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
editSourceType string
|
||||
editSourcePath string
|
||||
editSourceConn string
|
||||
editTargetType string
|
||||
editTargetPath string
|
||||
editSchemaFilter string
|
||||
)
|
||||
|
||||
var editCmd = &cobra.Command{
|
||||
Use: "edit",
|
||||
Short: "Edit database schema interactively with TUI",
|
||||
Long: `Edit database schemas from various formats using an interactive terminal UI.
|
||||
|
||||
Allows you to:
|
||||
- List and navigate schemas and tables
|
||||
- Create, edit, and delete schemas
|
||||
- Create, edit, and delete tables
|
||||
- Add, edit, and delete columns
|
||||
- Set table and column properties
|
||||
- Add constraints, indexes, and relationships
|
||||
|
||||
Supports reading from and writing to all supported formats:
|
||||
Input formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go, file or directory)
|
||||
- bun: Bun model files (Go, file or directory)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript, file or directory)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL database (live connection)
|
||||
|
||||
Output formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go)
|
||||
- bun: Bun model files (Go)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL SQL schema
|
||||
|
||||
PostgreSQL Connection String Examples:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
|
||||
Examples:
|
||||
# Edit a DBML schema file
|
||||
relspec edit --from dbml --from-path schema.dbml --to dbml --to-path schema.dbml
|
||||
|
||||
# Edit a PostgreSQL database
|
||||
relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to pgsql --to-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Edit JSON schema and output to GORM
|
||||
relspec edit --from json --from-path db.json --to gorm --to-path models/
|
||||
|
||||
# Edit GORM models in place
|
||||
relspec edit --from gorm --from-path ./models --to gorm --to-path ./models`,
|
||||
RunE: runEdit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
editCmd.Flags().StringVar(&editSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
editCmd.Flags().StringVar(&editTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editTargetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
|
||||
// Flags are now optional - if not provided, UI will prompt for load/save options
|
||||
}
|
||||
|
||||
func runEdit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Editor ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
var db *models.Database
|
||||
var loadConfig *ui.LoadConfig
|
||||
var saveConfig *ui.SaveConfig
|
||||
var err error
|
||||
|
||||
// Check if source parameters are provided
|
||||
if editSourceType != "" {
|
||||
// Read source database
|
||||
fmt.Fprintf(os.Stderr, "[1/3] Reading source schema...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", editSourceType)
|
||||
if editSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", editSourcePath)
|
||||
}
|
||||
if editSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(editSourceConn))
|
||||
}
|
||||
|
||||
db, err = readDatabaseForEdit(editSourceType, editSourcePath, editSourceConn, "Source")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
// Apply schema filter if specified
|
||||
if editSchemaFilter != "" {
|
||||
db = filterDatabaseBySchema(db, editSchemaFilter)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas))
|
||||
|
||||
totalTables := 0
|
||||
for _, schema := range db.Schemas {
|
||||
totalTables += len(schema.Tables)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables)
|
||||
|
||||
// Store load config
|
||||
loadConfig = &ui.LoadConfig{
|
||||
SourceType: editSourceType,
|
||||
FilePath: editSourcePath,
|
||||
ConnString: editSourceConn,
|
||||
}
|
||||
} else {
|
||||
// No source parameters provided, UI will show load screen
|
||||
fmt.Fprintf(os.Stderr, "[1/2] No source specified, editor will prompt for database\n\n")
|
||||
}
|
||||
|
||||
// Store save config if target parameters are provided
|
||||
if editTargetType != "" {
|
||||
saveConfig = &ui.SaveConfig{
|
||||
TargetType: editTargetType,
|
||||
FilePath: editTargetPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Launch interactive TUI
|
||||
if editSourceType != "" {
|
||||
fmt.Fprintf(os.Stderr, "[2/3] Launching interactive editor...\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Launching interactive editor...\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Use arrow keys and shortcuts to navigate\n")
|
||||
fmt.Fprintf(os.Stderr, " Press ? for help\n\n")
|
||||
|
||||
editor := ui.NewSchemaEditorWithConfigs(db, loadConfig, saveConfig)
|
||||
if err := editor.Run(); err != nil {
|
||||
return fmt.Errorf("editor failed: %w", err)
|
||||
}
|
||||
|
||||
// Only write to output if target parameters were provided and database was loaded from command line
|
||||
if editTargetType != "" && editSourceType != "" && db != nil {
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Writing changes to output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", editTargetType)
|
||||
if editTargetPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", editTargetPath)
|
||||
}
|
||||
|
||||
// Get the potentially modified database from the editor
|
||||
err = writeDatabaseForEdit(editTargetType, editTargetPath, "", editor.GetDatabase(), "Target")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully written database\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Edit complete ===\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabaseForEdit(dbType, filePath, connString string, db *models.Database, label string) error {
|
||||
var writer writers.Writer
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
451
cmd/relspec/merge.go
Normal file
451
cmd/relspec/merge.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/merge"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
wdctx "git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
wdrawdb "git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
wdrizzle "git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
mergeTargetType string
|
||||
mergeTargetPath string
|
||||
mergeTargetConn string
|
||||
mergeSourceType string
|
||||
mergeSourcePath string
|
||||
mergeSourceConn string
|
||||
mergeOutputType string
|
||||
mergeOutputPath string
|
||||
mergeOutputConn string
|
||||
mergeSkipDomains bool
|
||||
mergeSkipRelations bool
|
||||
mergeSkipEnums bool
|
||||
mergeSkipViews bool
|
||||
mergeSkipSequences bool
|
||||
mergeSkipTables string // Comma-separated table names to skip
|
||||
mergeVerbose bool
|
||||
mergeReportPath string // Path to write merge report
|
||||
)
|
||||
|
||||
var mergeCmd = &cobra.Command{
|
||||
Use: "merge",
|
||||
Short: "Merge database schemas (additive only - adds missing items)",
|
||||
Long: `Merge one database schema into another. Performs additive merging only:
|
||||
adds missing schemas, tables, columns, and other objects without modifying
|
||||
or deleting existing items.
|
||||
|
||||
The target database is loaded first, then the source database is merged into it.
|
||||
The result can be saved to a new format or updated in place.
|
||||
|
||||
Examples:
|
||||
# Merge two JSON schemas
|
||||
relspec merge --target json --target-path base.json \
|
||||
--source json --source-path additional.json \
|
||||
--output json --output-path merged.json
|
||||
|
||||
# Merge from PostgreSQL into JSON
|
||||
relspec merge --target json --target-path mydb.json \
|
||||
--source pgsql --source-conn "postgres://user:pass@localhost/source_db" \
|
||||
--output json --output-path combined.json
|
||||
|
||||
# Merge and execute on PostgreSQL database with report
|
||||
relspec merge --target json --target-path base.json \
|
||||
--source json --source-path additional.json \
|
||||
--output pgsql --output-conn "postgres://user:pass@localhost/target_db" \
|
||||
--merge-report merge-report.json
|
||||
|
||||
# Merge DBML and YAML, skip relations
|
||||
relspec merge --target dbml --target-path schema.dbml \
|
||||
--source yaml --source-path tables.yaml \
|
||||
--output dbml --output-path merged.dbml \
|
||||
--skip-relations
|
||||
|
||||
# Merge and save back to target format
|
||||
relspec merge --target json --target-path base.json \
|
||||
--source json --source-path patch.json \
|
||||
--output json --output-path base.json`,
|
||||
RunE: runMerge,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Target database flags
|
||||
mergeCmd.Flags().StringVar(&mergeTargetType, "target", "", "Target format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
|
||||
mergeCmd.Flags().StringVar(&mergeTargetPath, "target-path", "", "Target file path (required for file-based formats)")
|
||||
mergeCmd.Flags().StringVar(&mergeTargetConn, "target-conn", "", "Target connection string (required for pgsql)")
|
||||
|
||||
// Source database flags
|
||||
mergeCmd.Flags().StringVar(&mergeSourceType, "source", "", "Source format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
|
||||
mergeCmd.Flags().StringVar(&mergeSourcePath, "source-path", "", "Source file path (required for file-based formats)")
|
||||
mergeCmd.Flags().StringVar(&mergeSourceConn, "source-conn", "", "Source connection string (required for pgsql)")
|
||||
|
||||
// Output flags
|
||||
mergeCmd.Flags().StringVar(&mergeOutputType, "output", "", "Output format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
|
||||
mergeCmd.Flags().StringVar(&mergeOutputPath, "output-path", "", "Output file path (required for file-based formats)")
|
||||
mergeCmd.Flags().StringVar(&mergeOutputConn, "output-conn", "", "Output connection string (for pgsql)")
|
||||
|
||||
// Merge options
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipDomains, "skip-domains", false, "Skip domains during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipRelations, "skip-relations", false, "Skip relations during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipEnums, "skip-enums", false, "Skip enums during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipViews, "skip-views", false, "Skip views during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipSequences, "skip-sequences", false, "Skip sequences during merge")
|
||||
mergeCmd.Flags().StringVar(&mergeSkipTables, "skip-tables", "", "Comma-separated list of table names to skip during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeVerbose, "verbose", false, "Show verbose output")
|
||||
mergeCmd.Flags().StringVar(&mergeReportPath, "merge-report", "", "Path to write merge report (JSON format)")
|
||||
}
|
||||
|
||||
func runMerge(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Merge ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Validate required flags
|
||||
if mergeTargetType == "" {
|
||||
return fmt.Errorf("--target format is required")
|
||||
}
|
||||
if mergeSourceType == "" {
|
||||
return fmt.Errorf("--source format is required")
|
||||
}
|
||||
if mergeOutputType == "" {
|
||||
return fmt.Errorf("--output format is required")
|
||||
}
|
||||
|
||||
// Validate and expand file paths
|
||||
if mergeTargetType != "pgsql" {
|
||||
if mergeTargetPath == "" {
|
||||
return fmt.Errorf("--target-path is required for %s format", mergeTargetType)
|
||||
}
|
||||
mergeTargetPath = expandPath(mergeTargetPath)
|
||||
} else if mergeTargetConn == "" {
|
||||
|
||||
return fmt.Errorf("--target-conn is required for pgsql format")
|
||||
|
||||
}
|
||||
|
||||
if mergeSourceType != "pgsql" {
|
||||
if mergeSourcePath == "" {
|
||||
return fmt.Errorf("--source-path is required for %s format", mergeSourceType)
|
||||
}
|
||||
mergeSourcePath = expandPath(mergeSourcePath)
|
||||
} else if mergeSourceConn == "" {
|
||||
return fmt.Errorf("--source-conn is required for pgsql format")
|
||||
}
|
||||
|
||||
if mergeOutputType != "pgsql" {
|
||||
if mergeOutputPath == "" {
|
||||
return fmt.Errorf("--output-path is required for %s format", mergeOutputType)
|
||||
}
|
||||
mergeOutputPath = expandPath(mergeOutputPath)
|
||||
}
|
||||
|
||||
// Step 1: Read target database
|
||||
fmt.Fprintf(os.Stderr, "[1/3] Reading target database...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeTargetType)
|
||||
if mergeTargetPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeTargetPath)
|
||||
}
|
||||
if mergeTargetConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeTargetConn))
|
||||
}
|
||||
|
||||
targetDB, err := readDatabaseForMerge(mergeTargetType, mergeTargetPath, mergeTargetConn, "Target")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read target database: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read target database '%s'\n", targetDB.Name)
|
||||
printDatabaseStats(targetDB)
|
||||
|
||||
// Step 2: Read source database
|
||||
fmt.Fprintf(os.Stderr, "\n[2/3] Reading source database...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeSourceType)
|
||||
if mergeSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeSourcePath)
|
||||
}
|
||||
if mergeSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeSourceConn))
|
||||
}
|
||||
|
||||
sourceDB, err := readDatabaseForMerge(mergeSourceType, mergeSourcePath, mergeSourceConn, "Source")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source database: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read source database '%s'\n", sourceDB.Name)
|
||||
printDatabaseStats(sourceDB)
|
||||
|
||||
// Step 3: Merge databases
|
||||
fmt.Fprintf(os.Stderr, "\n[3/3] Merging databases...\n")
|
||||
|
||||
opts := &merge.MergeOptions{
|
||||
SkipDomains: mergeSkipDomains,
|
||||
SkipRelations: mergeSkipRelations,
|
||||
SkipEnums: mergeSkipEnums,
|
||||
SkipViews: mergeSkipViews,
|
||||
SkipSequences: mergeSkipSequences,
|
||||
}
|
||||
|
||||
// Parse skip-tables flag
|
||||
if mergeSkipTables != "" {
|
||||
opts.SkipTableNames = parseSkipTables(mergeSkipTables)
|
||||
if len(opts.SkipTableNames) > 0 {
|
||||
fmt.Fprintf(os.Stderr, " Skipping tables: %s\n", mergeSkipTables)
|
||||
}
|
||||
}
|
||||
|
||||
result := merge.MergeDatabases(targetDB, sourceDB, opts)
|
||||
|
||||
// Update timestamp
|
||||
targetDB.UpdateDate()
|
||||
|
||||
// Print merge summary
|
||||
fmt.Fprintf(os.Stderr, " ✓ Merge complete\n\n")
|
||||
fmt.Fprintf(os.Stderr, "%s\n", merge.GetMergeSummary(result))
|
||||
|
||||
// Step 4: Write output
|
||||
fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType)
|
||||
if mergeOutputPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeOutputPath)
|
||||
}
|
||||
|
||||
err = writeDatabaseForMerge(mergeOutputType, mergeOutputPath, mergeOutputConn, targetDB, "Output")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully written merged database\n")
|
||||
fmt.Fprintf(os.Stderr, "\n=== Merge complete ===\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format '%s'", label, dbType)
|
||||
}
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Database, label string) error {
|
||||
var writer writers.Writer
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "pgsql":
|
||||
writerOpts := &writers.WriterOptions{OutputPath: filePath}
|
||||
if connString != "" {
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
}
|
||||
// Add report path if merge report is enabled
|
||||
if mergeReportPath != "" {
|
||||
writerOpts.Metadata["report_path"] = mergeReportPath
|
||||
}
|
||||
}
|
||||
writer = wpgsql.NewWriter(writerOpts)
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported format '%s'", label, dbType)
|
||||
}
|
||||
|
||||
return writer.WriteDatabase(db)
|
||||
}
|
||||
|
||||
func expandPath(path string) string {
|
||||
if len(path) > 0 && path[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, path[1:])
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func printDatabaseStats(db *models.Database) {
|
||||
totalTables := 0
|
||||
totalColumns := 0
|
||||
totalConstraints := 0
|
||||
totalIndexes := 0
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
totalTables += len(schema.Tables)
|
||||
for _, table := range schema.Tables {
|
||||
totalColumns += len(table.Columns)
|
||||
totalConstraints += len(table.Constraints)
|
||||
totalIndexes += len(table.Indexes)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " Schemas: %d, Tables: %d, Columns: %d, Constraints: %d, Indexes: %d\n",
|
||||
len(db.Schemas), totalTables, totalColumns, totalConstraints, totalIndexes)
|
||||
}
|
||||
|
||||
func parseSkipTables(skipTablesStr string) map[string]bool {
|
||||
skipTables := make(map[string]bool)
|
||||
if skipTablesStr == "" {
|
||||
return skipTables
|
||||
}
|
||||
|
||||
// Split by comma and trim whitespace
|
||||
parts := strings.Split(skipTablesStr, ",")
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
// Store in lowercase for case-insensitive matching
|
||||
skipTables[strings.ToLower(trimmed)] = true
|
||||
}
|
||||
}
|
||||
|
||||
return skipTables
|
||||
}
|
||||
@@ -21,4 +21,7 @@ func init() {
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
scriptsDir string
|
||||
scriptsConn string
|
||||
scriptsSchemaName string
|
||||
scriptsDBName string
|
||||
scriptsDir string
|
||||
scriptsConn string
|
||||
scriptsSchemaName string
|
||||
scriptsDBName string
|
||||
scriptsIgnoreErrors bool
|
||||
)
|
||||
|
||||
var scriptsCmd = &cobra.Command{
|
||||
@@ -39,8 +40,8 @@ Example filenames (hyphen format):
|
||||
1-002-create-posts.sql # Priority 1, Sequence 2
|
||||
10-10-create-newid.pgsql # Priority 10, Sequence 10
|
||||
|
||||
Both formats can be mixed in the same directory.
|
||||
Scripts are executed in order: Priority (ascending), then Sequence (ascending).`,
|
||||
Both formats can be mixed in the same directory and subdirectories.
|
||||
Scripts are executed in order: Priority (ascending), Sequence (ascending), Name (alphabetical).`,
|
||||
}
|
||||
|
||||
var scriptsListCmd = &cobra.Command{
|
||||
@@ -48,8 +49,8 @@ var scriptsListCmd = &cobra.Command{
|
||||
Short: "List SQL scripts from a directory",
|
||||
Long: `List SQL scripts from a directory and show their execution order.
|
||||
|
||||
The scripts are read from the specified directory and displayed in the order
|
||||
they would be executed (Priority ascending, then Sequence ascending).
|
||||
The scripts are read recursively from the specified directory and displayed in the order
|
||||
they would be executed: Priority (ascending), then Sequence (ascending), then Name (alphabetical).
|
||||
|
||||
Example:
|
||||
relspec scripts list --dir ./migrations`,
|
||||
@@ -61,10 +62,10 @@ var scriptsExecuteCmd = &cobra.Command{
|
||||
Short: "Execute SQL scripts against a database",
|
||||
Long: `Execute SQL scripts from a directory against a PostgreSQL database.
|
||||
|
||||
Scripts are executed in order: Priority (ascending), then Sequence (ascending).
|
||||
Execution stops immediately on the first error.
|
||||
Scripts are executed in order: Priority (ascending), Sequence (ascending), Name (alphabetical).
|
||||
By default, execution stops immediately on the first error. Use --ignore-errors to continue execution.
|
||||
|
||||
The directory is scanned recursively for files matching the patterns:
|
||||
The directory is scanned recursively for all subdirectories and files matching the patterns:
|
||||
{priority}_{sequence}_{name}.sql or .pgsql (underscore format)
|
||||
{priority}-{sequence}-{name}.sql or .pgsql (hyphen format)
|
||||
|
||||
@@ -75,7 +76,7 @@ PostgreSQL Connection String Examples:
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
|
||||
Examples:
|
||||
# Execute migration scripts
|
||||
# Execute migration scripts from a directory (including subdirectories)
|
||||
relspec scripts execute --dir ./migrations \
|
||||
--conn "postgres://user:pass@localhost:5432/mydb"
|
||||
|
||||
@@ -86,7 +87,12 @@ Examples:
|
||||
|
||||
# Execute with SSL disabled
|
||||
relspec scripts execute --dir ./sql \
|
||||
--conn "postgres://user:pass@localhost/db?sslmode=disable"`,
|
||||
--conn "postgres://user:pass@localhost/db?sslmode=disable"
|
||||
|
||||
# Continue executing even if errors occur
|
||||
relspec scripts execute --dir ./migrations \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--ignore-errors`,
|
||||
RunE: runScriptsExecute,
|
||||
}
|
||||
|
||||
@@ -105,6 +111,7 @@ func init() {
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsConn, "conn", "", "PostgreSQL connection string (required)")
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsSchemaName, "schema", "public", "Schema name (optional, default: public)")
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsDBName, "database", "database", "Database name (optional, default: database)")
|
||||
scriptsExecuteCmd.Flags().BoolVar(&scriptsIgnoreErrors, "ignore-errors", false, "Continue executing scripts even if errors occur")
|
||||
|
||||
err = scriptsExecuteCmd.MarkFlagRequired("dir")
|
||||
if err != nil {
|
||||
@@ -149,7 +156,7 @@ func runScriptsList(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort scripts by Priority then Sequence
|
||||
// Sort scripts by Priority, Sequence, then Name
|
||||
sortedScripts := make([]*struct {
|
||||
name string
|
||||
priority int
|
||||
@@ -186,7 +193,10 @@ func runScriptsList(cmd *cobra.Command, args []string) error {
|
||||
if sortedScripts[i].priority != sortedScripts[j].priority {
|
||||
return sortedScripts[i].priority < sortedScripts[j].priority
|
||||
}
|
||||
return sortedScripts[i].sequence < sortedScripts[j].sequence
|
||||
if sortedScripts[i].sequence != sortedScripts[j].sequence {
|
||||
return sortedScripts[i].sequence < sortedScripts[j].sequence
|
||||
}
|
||||
return sortedScripts[i].name < sortedScripts[j].name
|
||||
})
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Found %d script(s) in execution order:\n\n", len(sortedScripts))
|
||||
@@ -242,22 +252,44 @@ func runScriptsExecute(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, " ✓ Found %d script(s)\n\n", len(schema.Scripts))
|
||||
|
||||
// Step 2: Execute scripts
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Executing scripts in order (Priority → Sequence)...\n\n")
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Executing scripts in order (Priority → Sequence → Name)...\n\n")
|
||||
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": scriptsConn,
|
||||
"ignore_errors": scriptsIgnoreErrors,
|
||||
},
|
||||
})
|
||||
|
||||
if err := writer.WriteSchema(schema); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
return fmt.Errorf("execution failed: %w", err)
|
||||
return fmt.Errorf("script execution failed: %w", err)
|
||||
}
|
||||
|
||||
// Get execution results from writer metadata
|
||||
totalCount := len(schema.Scripts)
|
||||
successCount := totalCount
|
||||
failedCount := 0
|
||||
|
||||
opts := writer.Options()
|
||||
if total, exists := opts.Metadata["execution_total"].(int); exists {
|
||||
totalCount = total
|
||||
}
|
||||
if success, exists := opts.Metadata["execution_success"].(int); exists {
|
||||
successCount = success
|
||||
}
|
||||
if failed, exists := opts.Metadata["execution_failed"].(int); exists {
|
||||
failedCount = failed
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Successfully executed %d script(s)\n\n", len(schema.Scripts))
|
||||
fmt.Fprintf(os.Stderr, "Total scripts: %d\n", totalCount)
|
||||
fmt.Fprintf(os.Stderr, "Successful: %d\n", successCount)
|
||||
if failedCount > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Failed: %d\n", failedCount)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
318
cmd/relspec/split.go
Normal file
318
cmd/relspec/split.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
var (
|
||||
splitSourceType string
|
||||
splitSourcePath string
|
||||
splitSourceConn string
|
||||
splitTargetType string
|
||||
splitTargetPath string
|
||||
splitSchemas string
|
||||
splitTables string
|
||||
splitPackageName string
|
||||
splitDatabaseName string
|
||||
splitExcludeSchema string
|
||||
splitExcludeTables string
|
||||
)
|
||||
|
||||
var splitCmd = &cobra.Command{
|
||||
Use: "split",
|
||||
Short: "Split database schemas to extract selected tables into a separate database",
|
||||
Long: `Extract selected schemas and tables from a database and write them to a separate output.
|
||||
|
||||
The split command allows you to:
|
||||
- Select specific schemas to include in the output
|
||||
- Select specific tables within schemas
|
||||
- Exclude specific schemas or tables if preferred
|
||||
- Export the selected subset to any supported format
|
||||
|
||||
Input formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go, file or directory)
|
||||
- bun: Bun model files (Go, file or directory)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript, file or directory)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL database (live connection)
|
||||
|
||||
Output formats:
|
||||
- dbml: DBML schema files
|
||||
- dctx: DCTX schema files
|
||||
- drawdb: DrawDB JSON files
|
||||
- graphql: GraphQL schema files (.graphql, SDL)
|
||||
- json: JSON database schema
|
||||
- yaml: YAML database schema
|
||||
- gorm: GORM model files (Go)
|
||||
- bun: Bun model files (Go)
|
||||
- drizzle: Drizzle ORM schema files (TypeScript)
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL SQL schema
|
||||
|
||||
Examples:
|
||||
# Split specific schemas from DBML
|
||||
relspec split --from dbml --from-path schema.dbml \
|
||||
--schemas public,auth \
|
||||
--to json --to-path subset.json
|
||||
|
||||
# Extract specific tables from PostgreSQL
|
||||
relspec split --from pgsql \
|
||||
--from-conn "postgres://user:pass@localhost:5432/mydb" \
|
||||
--schemas public \
|
||||
--tables users,orders,products \
|
||||
--to dbml --to-path subset.dbml
|
||||
|
||||
# Exclude specific tables
|
||||
relspec split --from json --from-path schema.json \
|
||||
--exclude-tables "audit_log,system_config,temp_data" \
|
||||
--to json --to-path public_schema.json
|
||||
|
||||
# Split and convert to GORM
|
||||
relspec split --from json --from-path schema.json \
|
||||
--tables "users,posts,comments" \
|
||||
--to gorm --to-path models/ --package models \
|
||||
--database-name MyAppDB
|
||||
|
||||
# Exclude specific schema and tables
|
||||
relspec split --from pgsql \
|
||||
--from-conn "postgres://user:pass@localhost/db" \
|
||||
--exclude-schema pg_catalog,information_schema \
|
||||
--exclude-tables "temp_users,debug_logs" \
|
||||
--to json --to-path public_schema.json`,
|
||||
RunE: runSplit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
splitCmd.Flags().StringVar(&splitSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
splitCmd.Flags().StringVar(&splitSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
splitCmd.Flags().StringVar(&splitSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
|
||||
splitCmd.Flags().StringVar(&splitTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
splitCmd.Flags().StringVar(&splitTargetPath, "to-path", "", "Target output path (file or directory)")
|
||||
splitCmd.Flags().StringVar(&splitPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
splitCmd.Flags().StringVar(&splitDatabaseName, "database-name", "", "Override database name in output")
|
||||
|
||||
splitCmd.Flags().StringVar(&splitSchemas, "schemas", "", "Comma-separated list of schema names to include")
|
||||
splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)")
|
||||
splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude")
|
||||
splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)")
|
||||
|
||||
err := splitCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
}
|
||||
err = splitCmd.MarkFlagRequired("to")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking to flag as required: %v\n", err)
|
||||
}
|
||||
err = splitCmd.MarkFlagRequired("to-path")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking to-path flag as required: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runSplit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Split ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Read source database
|
||||
fmt.Fprintf(os.Stderr, "[1/3] Reading source schema...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", splitSourceType)
|
||||
if splitSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", splitSourcePath)
|
||||
}
|
||||
if splitSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(splitSourceConn))
|
||||
}
|
||||
|
||||
db, err := readDatabaseForConvert(splitSourceType, splitSourcePath, splitSourceConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas))
|
||||
|
||||
totalTables := 0
|
||||
for _, schema := range db.Schemas {
|
||||
totalTables += len(schema.Tables)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables)
|
||||
|
||||
// Filter the database
|
||||
fmt.Fprintf(os.Stderr, "[2/3] Filtering schemas and tables...\n")
|
||||
filteredDB, err := filterDatabase(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to filter database: %w", err)
|
||||
}
|
||||
|
||||
if splitDatabaseName != "" {
|
||||
filteredDB.Name = splitDatabaseName
|
||||
}
|
||||
|
||||
filteredTables := 0
|
||||
for _, schema := range filteredDB.Schemas {
|
||||
filteredTables += len(schema.Tables)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Filtered to: %d schema(s), %d table(s)\n\n", len(filteredDB.Schemas), filteredTables)
|
||||
|
||||
// Write to target format
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Writing to target format...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", splitTargetType)
|
||||
fmt.Fprintf(os.Stderr, " Output: %s\n", splitTargetPath)
|
||||
if splitPackageName != "" {
|
||||
fmt.Fprintf(os.Stderr, " Package: %s\n", splitPackageName)
|
||||
}
|
||||
|
||||
err = writeDatabase(
|
||||
filteredDB,
|
||||
splitTargetType,
|
||||
splitTargetPath,
|
||||
splitPackageName,
|
||||
"", // no schema filter for split
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully written to '%s'\n\n", splitTargetPath)
|
||||
fmt.Fprintf(os.Stderr, "=== Split Completed Successfully ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Completed at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterDatabase filters the database based on provided criteria
|
||||
func filterDatabase(db *models.Database) (*models.Database, error) {
|
||||
filteredDB := &models.Database{
|
||||
Name: db.Name,
|
||||
Description: db.Description,
|
||||
Comment: db.Comment,
|
||||
DatabaseType: db.DatabaseType,
|
||||
DatabaseVersion: db.DatabaseVersion,
|
||||
SourceFormat: db.SourceFormat,
|
||||
UpdatedAt: db.UpdatedAt,
|
||||
GUID: db.GUID,
|
||||
Schemas: []*models.Schema{},
|
||||
Domains: db.Domains, // Keep domains for now
|
||||
}
|
||||
|
||||
// Parse filter flags
|
||||
includeSchemas := parseCommaSeparated(splitSchemas)
|
||||
includeTables := parseCommaSeparated(splitTables)
|
||||
excludeSchemas := parseCommaSeparated(splitExcludeSchema)
|
||||
excludeTables := parseCommaSeparated(splitExcludeTables)
|
||||
|
||||
// Convert table names to lowercase for case-insensitive matching
|
||||
includeTablesLower := make(map[string]bool)
|
||||
for _, t := range includeTables {
|
||||
includeTablesLower[strings.ToLower(t)] = true
|
||||
}
|
||||
|
||||
excludeTablesLower := make(map[string]bool)
|
||||
for _, t := range excludeTables {
|
||||
excludeTablesLower[strings.ToLower(t)] = true
|
||||
}
|
||||
|
||||
// Iterate through schemas
|
||||
for _, schema := range db.Schemas {
|
||||
// Check if schema should be excluded
|
||||
if contains(excludeSchemas, schema.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if schema should be included
|
||||
if len(includeSchemas) > 0 && !contains(includeSchemas, schema.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a copy of the schema with filtered tables
|
||||
filteredSchema := &models.Schema{
|
||||
Name: schema.Name,
|
||||
Description: schema.Description,
|
||||
Owner: schema.Owner,
|
||||
Permissions: schema.Permissions,
|
||||
Comment: schema.Comment,
|
||||
Metadata: schema.Metadata,
|
||||
Scripts: schema.Scripts,
|
||||
Sequence: schema.Sequence,
|
||||
Relations: schema.Relations,
|
||||
Enums: schema.Enums,
|
||||
UpdatedAt: schema.UpdatedAt,
|
||||
GUID: schema.GUID,
|
||||
Tables: []*models.Table{},
|
||||
Views: schema.Views,
|
||||
Sequences: schema.Sequences,
|
||||
}
|
||||
|
||||
// Filter tables within the schema
|
||||
for _, table := range schema.Tables {
|
||||
tableLower := strings.ToLower(table.Name)
|
||||
|
||||
// Check if table should be excluded
|
||||
if excludeTablesLower[tableLower] {
|
||||
continue
|
||||
}
|
||||
|
||||
// If specific tables are requested, only include those
|
||||
if len(includeTablesLower) > 0 {
|
||||
if !includeTablesLower[tableLower] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filteredSchema.Tables = append(filteredSchema.Tables, table)
|
||||
}
|
||||
|
||||
// Only add schema if it has tables (unless no table filter was specified)
|
||||
if len(filteredSchema.Tables) > 0 || (len(includeTablesLower) == 0 && len(excludeTablesLower) == 0) {
|
||||
filteredDB.Schemas = append(filteredDB.Schemas, filteredSchema)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredDB.Schemas) == 0 {
|
||||
return nil, fmt.Errorf("no schemas matched the filter criteria")
|
||||
}
|
||||
|
||||
return filteredDB, nil
|
||||
}
|
||||
|
||||
// parseCommaSeparated parses a comma-separated string into a slice, trimming whitespace
|
||||
func parseCommaSeparated(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// contains checks if a string is in a slice
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
7
go.mod
7
go.mod
@@ -3,8 +3,10 @@ module git.warky.dev/wdevs/relspecgo
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/uptrace/bun v1.2.16
|
||||
@@ -14,13 +16,17 @@ require (
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
@@ -28,4 +34,5 @@ require (
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
)
|
||||
|
||||
79
go.sum
79
go.sum
@@ -3,6 +3,11 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -21,11 +26,21 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
@@ -48,15 +63,79 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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/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=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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/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=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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/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=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
574
pkg/merge/merge.go
Normal file
574
pkg/merge/merge.go
Normal file
@@ -0,0 +1,574 @@
|
||||
// Package merge provides utilities for merging database schemas.
|
||||
// It allows combining schemas from multiple sources while avoiding duplicates,
|
||||
// supporting only additive operations (no deletion or modification of existing items).
|
||||
package merge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// MergeResult represents the result of a merge operation
|
||||
type MergeResult struct {
|
||||
SchemasAdded int
|
||||
TablesAdded int
|
||||
ColumnsAdded int
|
||||
RelationsAdded int
|
||||
DomainsAdded int
|
||||
EnumsAdded int
|
||||
ViewsAdded int
|
||||
SequencesAdded int
|
||||
}
|
||||
|
||||
// MergeOptions contains options for merge operations
|
||||
type MergeOptions struct {
|
||||
SkipDomains bool
|
||||
SkipRelations bool
|
||||
SkipEnums bool
|
||||
SkipViews bool
|
||||
SkipSequences bool
|
||||
SkipTableNames map[string]bool // Tables to skip during merge (keyed by table name)
|
||||
}
|
||||
|
||||
// MergeDatabases merges the source database into the target database.
|
||||
// Only adds missing items; existing items are not modified.
|
||||
func MergeDatabases(target, source *models.Database, opts *MergeOptions) *MergeResult {
|
||||
if opts == nil {
|
||||
opts = &MergeOptions{}
|
||||
}
|
||||
|
||||
result := &MergeResult{}
|
||||
|
||||
if target == nil || source == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
// Merge schemas and their contents
|
||||
result.merge(target, source, opts)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *MergeResult) merge(target, source *models.Database, opts *MergeOptions) {
|
||||
// Create maps of existing schemas for quick lookup
|
||||
existingSchemas := make(map[string]*models.Schema)
|
||||
for _, schema := range target.Schemas {
|
||||
existingSchemas[schema.SQLName()] = schema
|
||||
}
|
||||
|
||||
// Merge schemas
|
||||
for _, srcSchema := range source.Schemas {
|
||||
schemaName := srcSchema.SQLName()
|
||||
if tgtSchema, exists := existingSchemas[schemaName]; exists {
|
||||
// Schema exists, merge its contents
|
||||
r.mergeSchemaContents(tgtSchema, srcSchema, opts)
|
||||
} else {
|
||||
// Schema doesn't exist, add it
|
||||
newSchema := cloneSchema(srcSchema)
|
||||
target.Schemas = append(target.Schemas, newSchema)
|
||||
r.SchemasAdded++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge domains if not skipped
|
||||
if !opts.SkipDomains {
|
||||
r.mergeDomains(target, source)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeSchemaContents(target, source *models.Schema, opts *MergeOptions) {
|
||||
// Merge tables
|
||||
r.mergeTables(target, source, opts)
|
||||
|
||||
// Merge views if not skipped
|
||||
if !opts.SkipViews {
|
||||
r.mergeViews(target, source)
|
||||
}
|
||||
|
||||
// Merge sequences if not skipped
|
||||
if !opts.SkipSequences {
|
||||
r.mergeSequences(target, source)
|
||||
}
|
||||
|
||||
// Merge enums if not skipped
|
||||
if !opts.SkipEnums {
|
||||
r.mergeEnums(target, source)
|
||||
}
|
||||
|
||||
// Merge relations if not skipped
|
||||
if !opts.SkipRelations {
|
||||
r.mergeRelations(target, source)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeTables(schema *models.Schema, source *models.Schema, opts *MergeOptions) {
|
||||
// Create map of existing tables
|
||||
existingTables := make(map[string]*models.Table)
|
||||
for _, table := range schema.Tables {
|
||||
existingTables[table.SQLName()] = table
|
||||
}
|
||||
|
||||
// Merge tables
|
||||
for _, srcTable := range source.Tables {
|
||||
tableName := srcTable.SQLName()
|
||||
|
||||
// Skip if table is in the skip list (case-insensitive)
|
||||
if opts != nil && opts.SkipTableNames != nil && opts.SkipTableNames[strings.ToLower(tableName)] {
|
||||
continue
|
||||
}
|
||||
|
||||
if tgtTable, exists := existingTables[tableName]; exists {
|
||||
// Table exists, merge its columns
|
||||
r.mergeColumns(tgtTable, srcTable)
|
||||
} else {
|
||||
// Table doesn't exist, add it
|
||||
newTable := cloneTable(srcTable)
|
||||
schema.Tables = append(schema.Tables, newTable)
|
||||
r.TablesAdded++
|
||||
// Count columns in the newly added table
|
||||
r.ColumnsAdded += len(newTable.Columns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table) {
|
||||
// Create map of existing columns
|
||||
existingColumns := make(map[string]*models.Column)
|
||||
for colName := range table.Columns {
|
||||
existingColumns[colName] = table.Columns[colName]
|
||||
}
|
||||
|
||||
// Merge columns
|
||||
for colName, srcCol := range srcTable.Columns {
|
||||
if _, exists := existingColumns[colName]; !exists {
|
||||
// Column doesn't exist, add it
|
||||
newCol := cloneColumn(srcCol)
|
||||
table.Columns[colName] = newCol
|
||||
r.ColumnsAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeViews(schema *models.Schema, source *models.Schema) {
|
||||
// Create map of existing views
|
||||
existingViews := make(map[string]*models.View)
|
||||
for _, view := range schema.Views {
|
||||
existingViews[view.SQLName()] = view
|
||||
}
|
||||
|
||||
// Merge views
|
||||
for _, srcView := range source.Views {
|
||||
viewName := srcView.SQLName()
|
||||
if _, exists := existingViews[viewName]; !exists {
|
||||
// View doesn't exist, add it
|
||||
newView := cloneView(srcView)
|
||||
schema.Views = append(schema.Views, newView)
|
||||
r.ViewsAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeSequences(schema *models.Schema, source *models.Schema) {
|
||||
// Create map of existing sequences
|
||||
existingSequences := make(map[string]*models.Sequence)
|
||||
for _, seq := range schema.Sequences {
|
||||
existingSequences[seq.SQLName()] = seq
|
||||
}
|
||||
|
||||
// Merge sequences
|
||||
for _, srcSeq := range source.Sequences {
|
||||
seqName := srcSeq.SQLName()
|
||||
if _, exists := existingSequences[seqName]; !exists {
|
||||
// Sequence doesn't exist, add it
|
||||
newSeq := cloneSequence(srcSeq)
|
||||
schema.Sequences = append(schema.Sequences, newSeq)
|
||||
r.SequencesAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeEnums(schema *models.Schema, source *models.Schema) {
|
||||
// Create map of existing enums
|
||||
existingEnums := make(map[string]*models.Enum)
|
||||
for _, enum := range schema.Enums {
|
||||
existingEnums[enum.SQLName()] = enum
|
||||
}
|
||||
|
||||
// Merge enums
|
||||
for _, srcEnum := range source.Enums {
|
||||
enumName := srcEnum.SQLName()
|
||||
if _, exists := existingEnums[enumName]; !exists {
|
||||
// Enum doesn't exist, add it
|
||||
newEnum := cloneEnum(srcEnum)
|
||||
schema.Enums = append(schema.Enums, newEnum)
|
||||
r.EnumsAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeRelations(schema *models.Schema, source *models.Schema) {
|
||||
// Create map of existing relations
|
||||
existingRelations := make(map[string]*models.Relationship)
|
||||
for _, rel := range schema.Relations {
|
||||
existingRelations[rel.SQLName()] = rel
|
||||
}
|
||||
|
||||
// Merge relations
|
||||
for _, srcRel := range source.Relations {
|
||||
if _, exists := existingRelations[srcRel.SQLName()]; !exists {
|
||||
// Relation doesn't exist, add it
|
||||
newRel := cloneRelation(srcRel)
|
||||
schema.Relations = append(schema.Relations, newRel)
|
||||
r.RelationsAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeDomains(target *models.Database, source *models.Database) {
|
||||
// Create map of existing domains
|
||||
existingDomains := make(map[string]*models.Domain)
|
||||
for _, domain := range target.Domains {
|
||||
existingDomains[domain.SQLName()] = domain
|
||||
}
|
||||
|
||||
// Merge domains
|
||||
for _, srcDomain := range source.Domains {
|
||||
domainName := srcDomain.SQLName()
|
||||
if _, exists := existingDomains[domainName]; !exists {
|
||||
// Domain doesn't exist, add it
|
||||
newDomain := cloneDomain(srcDomain)
|
||||
target.Domains = append(target.Domains, newDomain)
|
||||
r.DomainsAdded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone functions to create deep copies of models
|
||||
|
||||
func cloneSchema(schema *models.Schema) *models.Schema {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
newSchema := &models.Schema{
|
||||
Name: schema.Name,
|
||||
Description: schema.Description,
|
||||
Owner: schema.Owner,
|
||||
Comment: schema.Comment,
|
||||
Sequence: schema.Sequence,
|
||||
UpdatedAt: schema.UpdatedAt,
|
||||
Tables: make([]*models.Table, 0),
|
||||
Views: make([]*models.View, 0),
|
||||
Sequences: make([]*models.Sequence, 0),
|
||||
Enums: make([]*models.Enum, 0),
|
||||
Relations: make([]*models.Relationship, 0),
|
||||
}
|
||||
|
||||
if schema.Permissions != nil {
|
||||
newSchema.Permissions = make(map[string]string)
|
||||
for k, v := range schema.Permissions {
|
||||
newSchema.Permissions[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if schema.Metadata != nil {
|
||||
newSchema.Metadata = make(map[string]interface{})
|
||||
for k, v := range schema.Metadata {
|
||||
newSchema.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if schema.Scripts != nil {
|
||||
newSchema.Scripts = make([]*models.Script, len(schema.Scripts))
|
||||
copy(newSchema.Scripts, schema.Scripts)
|
||||
}
|
||||
|
||||
// Clone tables
|
||||
for _, table := range schema.Tables {
|
||||
newSchema.Tables = append(newSchema.Tables, cloneTable(table))
|
||||
}
|
||||
|
||||
// Clone views
|
||||
for _, view := range schema.Views {
|
||||
newSchema.Views = append(newSchema.Views, cloneView(view))
|
||||
}
|
||||
|
||||
// Clone sequences
|
||||
for _, seq := range schema.Sequences {
|
||||
newSchema.Sequences = append(newSchema.Sequences, cloneSequence(seq))
|
||||
}
|
||||
|
||||
// Clone enums
|
||||
for _, enum := range schema.Enums {
|
||||
newSchema.Enums = append(newSchema.Enums, cloneEnum(enum))
|
||||
}
|
||||
|
||||
// Clone relations
|
||||
for _, rel := range schema.Relations {
|
||||
newSchema.Relations = append(newSchema.Relations, cloneRelation(rel))
|
||||
}
|
||||
|
||||
return newSchema
|
||||
}
|
||||
|
||||
func cloneTable(table *models.Table) *models.Table {
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
newTable := &models.Table{
|
||||
Name: table.Name,
|
||||
Description: table.Description,
|
||||
Schema: table.Schema,
|
||||
Comment: table.Comment,
|
||||
Sequence: table.Sequence,
|
||||
UpdatedAt: table.UpdatedAt,
|
||||
Columns: make(map[string]*models.Column),
|
||||
Constraints: make(map[string]*models.Constraint),
|
||||
Indexes: make(map[string]*models.Index),
|
||||
}
|
||||
|
||||
if table.Metadata != nil {
|
||||
newTable.Metadata = make(map[string]interface{})
|
||||
for k, v := range table.Metadata {
|
||||
newTable.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Clone columns
|
||||
for colName, col := range table.Columns {
|
||||
newTable.Columns[colName] = cloneColumn(col)
|
||||
}
|
||||
|
||||
// Clone constraints
|
||||
for constName, constraint := range table.Constraints {
|
||||
newTable.Constraints[constName] = cloneConstraint(constraint)
|
||||
}
|
||||
|
||||
// Clone indexes
|
||||
for idxName, index := range table.Indexes {
|
||||
newTable.Indexes[idxName] = cloneIndex(index)
|
||||
}
|
||||
|
||||
return newTable
|
||||
}
|
||||
|
||||
func cloneColumn(col *models.Column) *models.Column {
|
||||
if col == nil {
|
||||
return nil
|
||||
}
|
||||
newCol := &models.Column{
|
||||
Name: col.Name,
|
||||
Type: col.Type,
|
||||
Description: col.Description,
|
||||
Comment: col.Comment,
|
||||
IsPrimaryKey: col.IsPrimaryKey,
|
||||
NotNull: col.NotNull,
|
||||
Default: col.Default,
|
||||
Precision: col.Precision,
|
||||
Scale: col.Scale,
|
||||
Length: col.Length,
|
||||
Sequence: col.Sequence,
|
||||
AutoIncrement: col.AutoIncrement,
|
||||
Collation: col.Collation,
|
||||
}
|
||||
|
||||
return newCol
|
||||
}
|
||||
|
||||
func cloneConstraint(constraint *models.Constraint) *models.Constraint {
|
||||
if constraint == nil {
|
||||
return nil
|
||||
}
|
||||
newConstraint := &models.Constraint{
|
||||
Type: constraint.Type,
|
||||
Columns: make([]string, len(constraint.Columns)),
|
||||
ReferencedTable: constraint.ReferencedTable,
|
||||
ReferencedSchema: constraint.ReferencedSchema,
|
||||
ReferencedColumns: make([]string, len(constraint.ReferencedColumns)),
|
||||
OnUpdate: constraint.OnUpdate,
|
||||
OnDelete: constraint.OnDelete,
|
||||
Expression: constraint.Expression,
|
||||
Name: constraint.Name,
|
||||
Deferrable: constraint.Deferrable,
|
||||
InitiallyDeferred: constraint.InitiallyDeferred,
|
||||
Sequence: constraint.Sequence,
|
||||
}
|
||||
copy(newConstraint.Columns, constraint.Columns)
|
||||
copy(newConstraint.ReferencedColumns, constraint.ReferencedColumns)
|
||||
return newConstraint
|
||||
}
|
||||
|
||||
func cloneIndex(index *models.Index) *models.Index {
|
||||
if index == nil {
|
||||
return nil
|
||||
}
|
||||
newIndex := &models.Index{
|
||||
Name: index.Name,
|
||||
Description: index.Description,
|
||||
Table: index.Table,
|
||||
Schema: index.Schema,
|
||||
Columns: make([]string, len(index.Columns)),
|
||||
Unique: index.Unique,
|
||||
Type: index.Type,
|
||||
Where: index.Where,
|
||||
Concurrent: index.Concurrent,
|
||||
Include: make([]string, len(index.Include)),
|
||||
Comment: index.Comment,
|
||||
Sequence: index.Sequence,
|
||||
}
|
||||
copy(newIndex.Columns, index.Columns)
|
||||
copy(newIndex.Include, index.Include)
|
||||
return newIndex
|
||||
}
|
||||
|
||||
func cloneView(view *models.View) *models.View {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
newView := &models.View{
|
||||
Name: view.Name,
|
||||
Description: view.Description,
|
||||
Schema: view.Schema,
|
||||
Definition: view.Definition,
|
||||
Comment: view.Comment,
|
||||
Sequence: view.Sequence,
|
||||
Columns: make(map[string]*models.Column),
|
||||
}
|
||||
|
||||
if view.Metadata != nil {
|
||||
newView.Metadata = make(map[string]interface{})
|
||||
for k, v := range view.Metadata {
|
||||
newView.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Clone columns
|
||||
for colName, col := range view.Columns {
|
||||
newView.Columns[colName] = cloneColumn(col)
|
||||
}
|
||||
|
||||
return newView
|
||||
}
|
||||
|
||||
func cloneSequence(seq *models.Sequence) *models.Sequence {
|
||||
if seq == nil {
|
||||
return nil
|
||||
}
|
||||
newSeq := &models.Sequence{
|
||||
Name: seq.Name,
|
||||
Description: seq.Description,
|
||||
Schema: seq.Schema,
|
||||
StartValue: seq.StartValue,
|
||||
MinValue: seq.MinValue,
|
||||
MaxValue: seq.MaxValue,
|
||||
IncrementBy: seq.IncrementBy,
|
||||
CacheSize: seq.CacheSize,
|
||||
Cycle: seq.Cycle,
|
||||
OwnedByTable: seq.OwnedByTable,
|
||||
OwnedByColumn: seq.OwnedByColumn,
|
||||
Comment: seq.Comment,
|
||||
Sequence: seq.Sequence,
|
||||
}
|
||||
return newSeq
|
||||
}
|
||||
|
||||
func cloneEnum(enum *models.Enum) *models.Enum {
|
||||
if enum == nil {
|
||||
return nil
|
||||
}
|
||||
newEnum := &models.Enum{
|
||||
Name: enum.Name,
|
||||
Values: make([]string, len(enum.Values)),
|
||||
Schema: enum.Schema,
|
||||
}
|
||||
copy(newEnum.Values, enum.Values)
|
||||
return newEnum
|
||||
}
|
||||
|
||||
func cloneRelation(rel *models.Relationship) *models.Relationship {
|
||||
if rel == nil {
|
||||
return nil
|
||||
}
|
||||
newRel := &models.Relationship{
|
||||
Name: rel.Name,
|
||||
Type: rel.Type,
|
||||
FromTable: rel.FromTable,
|
||||
FromSchema: rel.FromSchema,
|
||||
FromColumns: make([]string, len(rel.FromColumns)),
|
||||
ToTable: rel.ToTable,
|
||||
ToSchema: rel.ToSchema,
|
||||
ToColumns: make([]string, len(rel.ToColumns)),
|
||||
ForeignKey: rel.ForeignKey,
|
||||
ThroughTable: rel.ThroughTable,
|
||||
ThroughSchema: rel.ThroughSchema,
|
||||
Description: rel.Description,
|
||||
Sequence: rel.Sequence,
|
||||
}
|
||||
|
||||
if rel.Properties != nil {
|
||||
newRel.Properties = make(map[string]string)
|
||||
for k, v := range rel.Properties {
|
||||
newRel.Properties[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
copy(newRel.FromColumns, rel.FromColumns)
|
||||
copy(newRel.ToColumns, rel.ToColumns)
|
||||
return newRel
|
||||
}
|
||||
|
||||
func cloneDomain(domain *models.Domain) *models.Domain {
|
||||
if domain == nil {
|
||||
return nil
|
||||
}
|
||||
newDomain := &models.Domain{
|
||||
Name: domain.Name,
|
||||
Description: domain.Description,
|
||||
Comment: domain.Comment,
|
||||
Sequence: domain.Sequence,
|
||||
Tables: make([]*models.DomainTable, len(domain.Tables)),
|
||||
}
|
||||
|
||||
if domain.Metadata != nil {
|
||||
newDomain.Metadata = make(map[string]interface{})
|
||||
for k, v := range domain.Metadata {
|
||||
newDomain.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
copy(newDomain.Tables, domain.Tables)
|
||||
return newDomain
|
||||
}
|
||||
|
||||
// GetMergeSummary returns a human-readable summary of the merge result
|
||||
func GetMergeSummary(result *MergeResult) string {
|
||||
if result == nil {
|
||||
return "No merge result available"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"=== Merge Summary ===",
|
||||
fmt.Sprintf("Schemas added: %d", result.SchemasAdded),
|
||||
fmt.Sprintf("Tables added: %d", result.TablesAdded),
|
||||
fmt.Sprintf("Columns added: %d", result.ColumnsAdded),
|
||||
fmt.Sprintf("Views added: %d", result.ViewsAdded),
|
||||
fmt.Sprintf("Sequences added: %d", result.SequencesAdded),
|
||||
fmt.Sprintf("Enums added: %d", result.EnumsAdded),
|
||||
fmt.Sprintf("Relations added: %d", result.RelationsAdded),
|
||||
fmt.Sprintf("Domains added: %d", result.DomainsAdded),
|
||||
}
|
||||
|
||||
totalAdded := result.SchemasAdded + result.TablesAdded + result.ColumnsAdded +
|
||||
result.ViewsAdded + result.SequencesAdded + result.EnumsAdded +
|
||||
result.RelationsAdded + result.DomainsAdded
|
||||
|
||||
lines = append(lines, fmt.Sprintf("Total items added: %d", totalAdded))
|
||||
|
||||
summary := ""
|
||||
for _, line := range lines {
|
||||
summary += line + "\n"
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
@@ -4,7 +4,12 @@
|
||||
// intermediate representation for converting between various database schema formats.
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DatabaseType represents the type of database system.
|
||||
type DatabaseType string
|
||||
@@ -26,6 +31,8 @@ type Database struct {
|
||||
DatabaseType DatabaseType `json:"database_type,omitempty" yaml:"database_type,omitempty" xml:"database_type,omitempty"`
|
||||
DatabaseVersion string `json:"database_version,omitempty" yaml:"database_version,omitempty" xml:"database_version,omitempty"`
|
||||
SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty" xml:"source_format,omitempty"` // Source Format of the database.
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the database name in lowercase for SQL compatibility.
|
||||
@@ -33,6 +40,11 @@ func (d *Database) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// UpdateDate sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Database) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Domain represents a logical business domain grouping multiple tables from potentially different schemas.
|
||||
// Domains allow for organizing database tables by functional areas (e.g., authentication, user data, financial).
|
||||
type Domain struct {
|
||||
@@ -42,6 +54,7 @@ type Domain struct {
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the domain name in lowercase for SQL compatibility.
|
||||
@@ -57,6 +70,7 @@ type DomainTable struct {
|
||||
SchemaName string `json:"schema_name" yaml:"schema_name" xml:"schema_name"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
RefTable *Table `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// Schema represents a database schema, which is a logical grouping of database objects
|
||||
@@ -76,6 +90,16 @@ type Schema struct {
|
||||
RefDatabase *Database `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
Relations []*Relationship `json:"relations,omitempty" yaml:"relations,omitempty" xml:"-"`
|
||||
Enums []*Enum `json:"enums,omitempty" yaml:"enums,omitempty" xml:"enums"`
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// UpdaUpdateDateted sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Schema) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if d.RefDatabase != nil {
|
||||
d.RefDatabase.UpdateDate()
|
||||
}
|
||||
}
|
||||
|
||||
// SQLName returns the schema name in lowercase for SQL compatibility.
|
||||
@@ -98,6 +122,16 @@ type Table struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// UpdateDate sets the UpdatedAt field to the current time in RFC3339 format.
|
||||
func (d *Table) UpdateDate() {
|
||||
d.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if d.RefSchema != nil {
|
||||
d.RefSchema.UpdateDate()
|
||||
}
|
||||
}
|
||||
|
||||
// SQLName returns the table name in lowercase for SQL compatibility.
|
||||
@@ -138,6 +172,7 @@ type View struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the view name in lowercase for SQL compatibility.
|
||||
@@ -161,6 +196,7 @@ type Sequence struct {
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the sequence name in lowercase for SQL compatibility.
|
||||
@@ -185,6 +221,7 @@ type Column struct {
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the column name in lowercase for SQL compatibility.
|
||||
@@ -207,6 +244,7 @@ type Index struct {
|
||||
Include []string `json:"include,omitempty" yaml:"include,omitempty" xml:"include,omitempty"` // INCLUDE columns
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the index name in lowercase for SQL compatibility.
|
||||
@@ -241,6 +279,7 @@ type Relationship struct {
|
||||
ThroughSchema string `json:"through_schema,omitempty" yaml:"through_schema,omitempty" xml:"through_schema,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the relationship name in lowercase for SQL compatibility.
|
||||
@@ -265,6 +304,7 @@ type Constraint struct {
|
||||
Deferrable bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,omitempty"`
|
||||
InitiallyDeferred bool `json:"initially_deferred,omitempty" yaml:"initially_deferred,omitempty" xml:"initially_deferred,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the constraint name in lowercase for SQL compatibility.
|
||||
@@ -280,6 +320,7 @@ type Enum struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Values []string `json:"values" yaml:"values" xml:"values"`
|
||||
Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the enum name in lowercase for SQL compatibility.
|
||||
@@ -287,6 +328,16 @@ func (d *Enum) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// InitEnum initializes a new Enum with empty values slice
|
||||
func InitEnum(name, schema string) *Enum {
|
||||
return &Enum{
|
||||
Name: name,
|
||||
Schema: schema,
|
||||
Values: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Supported constraint types.
|
||||
const (
|
||||
PrimaryKeyConstraint ConstraintType = "primary_key" // Primary key uniquely identifies each record
|
||||
@@ -308,6 +359,7 @@ type Script struct {
|
||||
Version string `json:"version,omitempty" yaml:"version,omitempty" xml:"version,omitempty"`
|
||||
Priority int `json:"priority,omitempty" yaml:"priority,omitempty" xml:"priority,omitempty"`
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
// SQLName returns the script name in lowercase for SQL compatibility.
|
||||
@@ -323,6 +375,7 @@ func InitDatabase(name string) *Database {
|
||||
Name: name,
|
||||
Schemas: make([]*Schema, 0),
|
||||
Domains: make([]*Domain, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +389,7 @@ func InitSchema(name string) *Schema {
|
||||
Permissions: make(map[string]string),
|
||||
Metadata: make(map[string]any),
|
||||
Scripts: make([]*Script, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +403,7 @@ func InitTable(name, schema string) *Table {
|
||||
Indexes: make(map[string]*Index),
|
||||
Relationships: make(map[string]*Relationship),
|
||||
Metadata: make(map[string]any),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +413,7 @@ func InitColumn(name, table, schema string) *Column {
|
||||
Name: name,
|
||||
Table: table,
|
||||
Schema: schema,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +425,7 @@ func InitIndex(name, table, schema string) *Index {
|
||||
Schema: schema,
|
||||
Columns: make([]string, 0),
|
||||
Include: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +438,7 @@ func InitRelation(name, schema string) *Relationship {
|
||||
Properties: make(map[string]string),
|
||||
FromColumns: make([]string, 0),
|
||||
ToColumns: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +448,7 @@ func InitRelationship(name string, relType RelationType) *Relationship {
|
||||
Name: name,
|
||||
Type: relType,
|
||||
Properties: make(map[string]string),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +459,7 @@ func InitConstraint(name string, constraintType ConstraintType) *Constraint {
|
||||
Type: constraintType,
|
||||
Columns: make([]string, 0),
|
||||
ReferencedColumns: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +468,7 @@ func InitScript(name string) *Script {
|
||||
return &Script{
|
||||
Name: name,
|
||||
RunAfter: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +479,7 @@ func InitView(name, schema string) *View {
|
||||
Schema: schema,
|
||||
Columns: make(map[string]*Column),
|
||||
Metadata: make(map[string]any),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +490,7 @@ func InitSequence(name, schema string) *Sequence {
|
||||
Schema: schema,
|
||||
IncrementBy: 1,
|
||||
StartValue: 1,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +500,7 @@ func InitDomain(name string) *Domain {
|
||||
Name: name,
|
||||
Tables: make([]*DomainTable, 0),
|
||||
Metadata: make(map[string]any),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,5 +509,6 @@ func InitDomainTable(tableName, schemaName string) *DomainTable {
|
||||
return &DomainTable{
|
||||
TableName: tableName,
|
||||
SchemaName: schemaName,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,31 +4,31 @@ import "strings"
|
||||
|
||||
var GoToStdTypes = map[string]string{
|
||||
"bool": "boolean",
|
||||
"int64": "integer",
|
||||
"int64": "bigint",
|
||||
"int": "integer",
|
||||
"int8": "integer",
|
||||
"int16": "integer",
|
||||
"int8": "smallint",
|
||||
"int16": "smallint",
|
||||
"int32": "integer",
|
||||
"uint": "integer",
|
||||
"uint8": "integer",
|
||||
"uint16": "integer",
|
||||
"uint8": "smallint",
|
||||
"uint16": "smallint",
|
||||
"uint32": "integer",
|
||||
"uint64": "integer",
|
||||
"uintptr": "integer",
|
||||
"znullint64": "integer",
|
||||
"uint64": "bigint",
|
||||
"uintptr": "bigint",
|
||||
"znullint64": "bigint",
|
||||
"znullint32": "integer",
|
||||
"znullbyte": "integer",
|
||||
"znullbyte": "smallint",
|
||||
"float64": "double",
|
||||
"float32": "double",
|
||||
"complex64": "double",
|
||||
"complex128": "double",
|
||||
"customfloat64": "double",
|
||||
"string": "string",
|
||||
"Pointer": "integer",
|
||||
"string": "text",
|
||||
"Pointer": "bigint",
|
||||
"[]byte": "blob",
|
||||
"customdate": "string",
|
||||
"customtime": "string",
|
||||
"customtimestamp": "string",
|
||||
"customdate": "date",
|
||||
"customtime": "time",
|
||||
"customtimestamp": "timestamp",
|
||||
"sqlfloat64": "double",
|
||||
"sqlfloat16": "double",
|
||||
"sqluuid": "uuid",
|
||||
@@ -36,9 +36,9 @@ var GoToStdTypes = map[string]string{
|
||||
"sqljson": "json",
|
||||
"sqlint64": "bigint",
|
||||
"sqlint32": "integer",
|
||||
"sqlint16": "integer",
|
||||
"sqlint16": "smallint",
|
||||
"sqlbool": "boolean",
|
||||
"sqlstring": "string",
|
||||
"sqlstring": "text",
|
||||
"nullablejsonb": "jsonb",
|
||||
"nullablejson": "json",
|
||||
"nullableuuid": "uuid",
|
||||
@@ -67,7 +67,7 @@ var GoToPGSQLTypes = map[string]string{
|
||||
"float32": "real",
|
||||
"complex64": "double precision",
|
||||
"complex128": "double precision",
|
||||
"customfloat64": "double precisio",
|
||||
"customfloat64": "double precision",
|
||||
"string": "text",
|
||||
"Pointer": "bigint",
|
||||
"[]byte": "bytea",
|
||||
@@ -81,9 +81,9 @@ var GoToPGSQLTypes = map[string]string{
|
||||
"sqljson": "json",
|
||||
"sqlint64": "bigint",
|
||||
"sqlint32": "integer",
|
||||
"sqlint16": "integer",
|
||||
"sqlint16": "smallint",
|
||||
"sqlbool": "boolean",
|
||||
"sqlstring": "string",
|
||||
"sqlstring": "text",
|
||||
"nullablejsonb": "jsonb",
|
||||
"nullablejson": "json",
|
||||
"nullableuuid": "uuid",
|
||||
|
||||
@@ -632,6 +632,9 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
column.Name = parts[0]
|
||||
}
|
||||
|
||||
// Track if we found explicit nullability markers
|
||||
hasExplicitNullableMarker := false
|
||||
|
||||
// Parse tag attributes
|
||||
for _, part := range parts[1:] {
|
||||
kv := strings.SplitN(part, ":", 2)
|
||||
@@ -649,6 +652,10 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
column.IsPrimaryKey = true
|
||||
case "notnull":
|
||||
column.NotNull = true
|
||||
hasExplicitNullableMarker = true
|
||||
case "nullzero":
|
||||
column.NotNull = false
|
||||
hasExplicitNullableMarker = true
|
||||
case "autoincrement":
|
||||
column.AutoIncrement = true
|
||||
case "default":
|
||||
@@ -664,17 +671,15 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
|
||||
// Determine if nullable based on Go type and bun tags
|
||||
// In Bun:
|
||||
// - nullzero tag means the field is nullable (can be NULL in DB)
|
||||
// - absence of nullzero means the field is NOT NULL
|
||||
// - primitive types (int64, bool, string) are NOT NULL by default
|
||||
column.NotNull = true
|
||||
// Primary keys are always NOT NULL
|
||||
|
||||
if strings.Contains(bunTag, "nullzero") {
|
||||
column.NotNull = false
|
||||
} else {
|
||||
// - explicit "notnull" tag means NOT NULL
|
||||
// - explicit "nullzero" tag means nullable
|
||||
// - absence of explicit markers: infer from Go type
|
||||
if !hasExplicitNullableMarker {
|
||||
// Infer from Go type if no explicit marker found
|
||||
column.NotNull = !r.isNullableGoType(fieldType)
|
||||
}
|
||||
|
||||
// Primary keys are always NOT NULL
|
||||
if column.IsPrimaryKey {
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -24,11 +26,23 @@ func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
}
|
||||
|
||||
// ReadDatabase reads and parses DBML input, returning a Database model
|
||||
// If FilePath points to a directory, all .dbml files are loaded and merged
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML reader")
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
info, err := os.Stat(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat path: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return r.readDirectoryDBML(r.options.FilePath)
|
||||
}
|
||||
|
||||
// Single file - existing logic
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
@@ -67,15 +81,341 @@ func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// stripQuotes removes surrounding quotes from an identifier
|
||||
// readDirectoryDBML processes all .dbml files in directory
|
||||
// Returns merged Database model
|
||||
func (r *Reader) readDirectoryDBML(dirPath string) (*models.Database, error) {
|
||||
// Discover and sort DBML files
|
||||
files, err := r.discoverDBMLFiles(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to discover DBML files: %w", err)
|
||||
}
|
||||
|
||||
// If no files found, return empty database
|
||||
if len(files) == 0 {
|
||||
db := models.InitDatabase("database")
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
db.Name = name
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Initialize database (will be merged with files)
|
||||
var db *models.Database
|
||||
|
||||
// Process each file in sorted order
|
||||
for _, filePath := range files {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
fileDB, err := r.parseDBML(string(content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// First file initializes the database
|
||||
if db == nil {
|
||||
db = fileDB
|
||||
} else {
|
||||
// Subsequent files are merged
|
||||
mergeDatabase(db, fileDB)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// splitIdentifier splits a dotted identifier while respecting quotes
|
||||
// Handles cases like: "schema.with.dots"."table"."column"
|
||||
func splitIdentifier(s string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
|
||||
if !inQuote {
|
||||
switch ch {
|
||||
case '"', '\'':
|
||||
inQuote = true
|
||||
quoteChar = ch
|
||||
current.WriteByte(ch)
|
||||
case '.':
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
} else {
|
||||
current.WriteByte(ch)
|
||||
if ch == quoteChar {
|
||||
inQuote = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// stripQuotes removes surrounding quotes and comments from an identifier
|
||||
func stripQuotes(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Remove DBML comments in brackets (e.g., [note: 'description'])
|
||||
// This handles inline comments like: "table_name" [note: 'comment']
|
||||
commentRegex := regexp.MustCompile(`\s*\[.*?\]\s*`)
|
||||
s = commentRegex.ReplaceAllString(s, "")
|
||||
|
||||
// Trim again after removing comments
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Remove surrounding quotes (double or single)
|
||||
if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseFilePrefix extracts numeric prefix from filename
|
||||
// Examples: "1_schema.dbml" -> (1, true), "tables.dbml" -> (0, false)
|
||||
func parseFilePrefix(filename string) (int, bool) {
|
||||
base := filepath.Base(filename)
|
||||
re := regexp.MustCompile(`^(\d+)[_-]`)
|
||||
matches := re.FindStringSubmatch(base)
|
||||
if len(matches) > 1 {
|
||||
var prefix int
|
||||
_, err := fmt.Sscanf(matches[1], "%d", &prefix)
|
||||
if err == nil {
|
||||
return prefix, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// hasCommentedRefs scans file content for commented-out Ref statements
|
||||
// Returns true if file contains lines like: // Ref: table.col > other.col
|
||||
func hasCommentedRefs(filePath string) (bool, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||
commentedRefRegex := regexp.MustCompile(`^\s*//.*Ref:\s+`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if commentedRefRegex.MatchString(line) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// discoverDBMLFiles finds all .dbml files in directory and returns them sorted
|
||||
func (r *Reader) discoverDBMLFiles(dirPath string) ([]string, error) {
|
||||
pattern := filepath.Join(dirPath, "*.dbml")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to glob .dbml files: %w", err)
|
||||
}
|
||||
|
||||
return sortDBMLFiles(files), nil
|
||||
}
|
||||
|
||||
// sortDBMLFiles sorts files by:
|
||||
// 1. Files without commented refs (by numeric prefix, then alphabetically)
|
||||
// 2. Files with commented refs (by numeric prefix, then alphabetically)
|
||||
func sortDBMLFiles(files []string) []string {
|
||||
// Create a slice to hold file info for sorting
|
||||
type fileInfo struct {
|
||||
path string
|
||||
hasCommented bool
|
||||
prefix int
|
||||
hasPrefix bool
|
||||
basename string
|
||||
}
|
||||
|
||||
fileInfos := make([]fileInfo, 0, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
hasCommented, err := hasCommentedRefs(file)
|
||||
if err != nil {
|
||||
// If we can't read the file, treat it as not having commented refs
|
||||
hasCommented = false
|
||||
}
|
||||
|
||||
prefix, hasPrefix := parseFilePrefix(file)
|
||||
basename := filepath.Base(file)
|
||||
|
||||
fileInfos = append(fileInfos, fileInfo{
|
||||
path: file,
|
||||
hasCommented: hasCommented,
|
||||
prefix: prefix,
|
||||
hasPrefix: hasPrefix,
|
||||
basename: basename,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by: hasCommented (false first), hasPrefix (true first), prefix, basename
|
||||
sort.Slice(fileInfos, func(i, j int) bool {
|
||||
// First, sort by commented refs (files without commented refs come first)
|
||||
if fileInfos[i].hasCommented != fileInfos[j].hasCommented {
|
||||
return !fileInfos[i].hasCommented
|
||||
}
|
||||
|
||||
// Then by presence of prefix (files with prefix come first)
|
||||
if fileInfos[i].hasPrefix != fileInfos[j].hasPrefix {
|
||||
return fileInfos[i].hasPrefix
|
||||
}
|
||||
|
||||
// If both have prefix, sort by prefix value
|
||||
if fileInfos[i].hasPrefix && fileInfos[j].hasPrefix {
|
||||
if fileInfos[i].prefix != fileInfos[j].prefix {
|
||||
return fileInfos[i].prefix < fileInfos[j].prefix
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, sort alphabetically by basename
|
||||
return fileInfos[i].basename < fileInfos[j].basename
|
||||
})
|
||||
|
||||
// Extract sorted paths
|
||||
sortedFiles := make([]string, len(fileInfos))
|
||||
for i, info := range fileInfos {
|
||||
sortedFiles[i] = info.path
|
||||
}
|
||||
|
||||
return sortedFiles
|
||||
}
|
||||
|
||||
// mergeTable combines two table definitions
|
||||
// Merges: Columns (map), Constraints (map), Indexes (map), Relationships (map)
|
||||
// Uses first non-empty Description
|
||||
func mergeTable(baseTable, fileTable *models.Table) {
|
||||
// Merge columns (map naturally merges - later keys overwrite)
|
||||
for key, col := range fileTable.Columns {
|
||||
baseTable.Columns[key] = col
|
||||
}
|
||||
|
||||
// Merge constraints
|
||||
for key, constraint := range fileTable.Constraints {
|
||||
baseTable.Constraints[key] = constraint
|
||||
}
|
||||
|
||||
// Merge indexes
|
||||
for key, index := range fileTable.Indexes {
|
||||
baseTable.Indexes[key] = index
|
||||
}
|
||||
|
||||
// Merge relationships
|
||||
for key, rel := range fileTable.Relationships {
|
||||
baseTable.Relationships[key] = rel
|
||||
}
|
||||
|
||||
// Use first non-empty description
|
||||
if baseTable.Description == "" && fileTable.Description != "" {
|
||||
baseTable.Description = fileTable.Description
|
||||
}
|
||||
|
||||
// Merge metadata maps
|
||||
if baseTable.Metadata == nil {
|
||||
baseTable.Metadata = make(map[string]any)
|
||||
}
|
||||
for key, val := range fileTable.Metadata {
|
||||
baseTable.Metadata[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSchema finds or creates schema and merges tables
|
||||
func mergeSchema(baseDB *models.Database, fileSchema *models.Schema) {
|
||||
// Find existing schema by name (normalize names by stripping quotes)
|
||||
var existingSchema *models.Schema
|
||||
fileSchemaName := stripQuotes(fileSchema.Name)
|
||||
for _, schema := range baseDB.Schemas {
|
||||
if stripQuotes(schema.Name) == fileSchemaName {
|
||||
existingSchema = schema
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If schema doesn't exist, add it and return
|
||||
if existingSchema == nil {
|
||||
baseDB.Schemas = append(baseDB.Schemas, fileSchema)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge tables from fileSchema into existingSchema
|
||||
for _, fileTable := range fileSchema.Tables {
|
||||
// Find existing table by name (normalize names by stripping quotes)
|
||||
var existingTable *models.Table
|
||||
fileTableName := stripQuotes(fileTable.Name)
|
||||
for _, table := range existingSchema.Tables {
|
||||
if stripQuotes(table.Name) == fileTableName {
|
||||
existingTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If table doesn't exist, add it
|
||||
if existingTable == nil {
|
||||
existingSchema.Tables = append(existingSchema.Tables, fileTable)
|
||||
} else {
|
||||
// Merge table properties - tables are identical, skip
|
||||
mergeTable(existingTable, fileTable)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge other schema properties
|
||||
existingSchema.Views = append(existingSchema.Views, fileSchema.Views...)
|
||||
existingSchema.Sequences = append(existingSchema.Sequences, fileSchema.Sequences...)
|
||||
existingSchema.Scripts = append(existingSchema.Scripts, fileSchema.Scripts...)
|
||||
|
||||
// Merge permissions
|
||||
if existingSchema.Permissions == nil {
|
||||
existingSchema.Permissions = make(map[string]string)
|
||||
}
|
||||
for key, val := range fileSchema.Permissions {
|
||||
existingSchema.Permissions[key] = val
|
||||
}
|
||||
|
||||
// Merge metadata
|
||||
if existingSchema.Metadata == nil {
|
||||
existingSchema.Metadata = make(map[string]any)
|
||||
}
|
||||
for key, val := range fileSchema.Metadata {
|
||||
existingSchema.Metadata[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// mergeDatabase merges schemas from fileDB into baseDB
|
||||
func mergeDatabase(baseDB, fileDB *models.Database) {
|
||||
// Merge each schema from fileDB
|
||||
for _, fileSchema := range fileDB.Schemas {
|
||||
mergeSchema(baseDB, fileSchema)
|
||||
}
|
||||
|
||||
// Merge domains
|
||||
baseDB.Domains = append(baseDB.Domains, fileDB.Domains...)
|
||||
|
||||
// Use first non-empty description
|
||||
if baseDB.Description == "" && fileDB.Description != "" {
|
||||
baseDB.Description = fileDB.Description
|
||||
}
|
||||
}
|
||||
|
||||
// parseDBML parses DBML content and returns a Database model
|
||||
func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
@@ -109,7 +449,9 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
// Parse Table definition
|
||||
if matches := tableRegex.FindStringSubmatch(line); matches != nil {
|
||||
tableName := matches[1]
|
||||
parts := strings.Split(tableName, ".")
|
||||
// Strip comments/notes before parsing to avoid dots in notes
|
||||
tableName = strings.TrimSpace(regexp.MustCompile(`\s*\[.*?\]\s*`).ReplaceAllString(tableName, ""))
|
||||
parts := splitIdentifier(tableName)
|
||||
|
||||
if len(parts) == 2 {
|
||||
currentSchema = stripQuotes(parts[0])
|
||||
@@ -287,10 +629,10 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
|
||||
refOp := strings.TrimSpace(refStr)
|
||||
var isReverse bool
|
||||
if strings.HasPrefix(refOp, "<") {
|
||||
isReverse = column.IsPrimaryKey // < on PK means "is referenced by" (reverse)
|
||||
} else if strings.HasPrefix(refOp, ">") {
|
||||
isReverse = !column.IsPrimaryKey // > on FK means reverse
|
||||
// < means "is referenced by" - only makes sense on PK columns
|
||||
isReverse = column.IsPrimaryKey
|
||||
}
|
||||
// > means "references" - always a forward FK, never reverse
|
||||
|
||||
constraint = r.parseRef(refStr)
|
||||
if constraint != nil {
|
||||
@@ -332,27 +674,31 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
||||
// Format: (columns) [attributes] OR columnname [attributes]
|
||||
var columns []string
|
||||
|
||||
if strings.Contains(line, "(") && strings.Contains(line, ")") {
|
||||
// Find the attributes section to avoid parsing parentheses in notes/attributes
|
||||
attrStart := strings.Index(line, "[")
|
||||
columnPart := line
|
||||
if attrStart > 0 {
|
||||
columnPart = line[:attrStart]
|
||||
}
|
||||
|
||||
if strings.Contains(columnPart, "(") && strings.Contains(columnPart, ")") {
|
||||
// Multi-column format: (col1, col2) [attributes]
|
||||
colStart := strings.Index(line, "(")
|
||||
colEnd := strings.Index(line, ")")
|
||||
colStart := strings.Index(columnPart, "(")
|
||||
colEnd := strings.Index(columnPart, ")")
|
||||
if colStart >= colEnd {
|
||||
return nil
|
||||
}
|
||||
|
||||
columnsStr := line[colStart+1 : colEnd]
|
||||
columnsStr := columnPart[colStart+1 : colEnd]
|
||||
for _, col := range strings.Split(columnsStr, ",") {
|
||||
columns = append(columns, stripQuotes(strings.TrimSpace(col)))
|
||||
}
|
||||
} else if strings.Contains(line, "[") {
|
||||
} else if attrStart > 0 {
|
||||
// Single column format: columnname [attributes]
|
||||
// Extract column name before the bracket
|
||||
idx := strings.Index(line, "[")
|
||||
if idx > 0 {
|
||||
colName := strings.TrimSpace(line[:idx])
|
||||
if colName != "" {
|
||||
columns = []string{stripQuotes(colName)}
|
||||
}
|
||||
colName := strings.TrimSpace(columnPart)
|
||||
if colName != "" {
|
||||
columns = []string{stripQuotes(colName)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +856,7 @@ func (r *Reader) parseTableRef(ref string) (schema, table string, columns []stri
|
||||
}
|
||||
|
||||
// Parse schema, table, and optionally column
|
||||
parts := strings.Split(strings.TrimSpace(ref), ".")
|
||||
parts := splitIdentifier(strings.TrimSpace(ref))
|
||||
if len(parts) == 3 {
|
||||
// Format: "schema"."table"."column"
|
||||
schema = stripQuotes(parts[0])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -517,3 +518,286 @@ func TestGetForeignKeys(t *testing.T) {
|
||||
t.Error("Expected foreign key constraint type")
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for multi-file directory loading
|
||||
|
||||
func TestReadDirectory_MultipleFiles(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
t.Fatal("ReadDatabase() returned nil database")
|
||||
}
|
||||
|
||||
// Should have public schema
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("Expected at least one schema")
|
||||
}
|
||||
|
||||
var publicSchema *models.Schema
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name == "public" {
|
||||
publicSchema = schema
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if publicSchema == nil {
|
||||
t.Fatal("Public schema not found")
|
||||
}
|
||||
|
||||
// Should have 3 tables: users, posts, comments
|
||||
if len(publicSchema.Tables) != 3 {
|
||||
t.Fatalf("Expected 3 tables, got %d", len(publicSchema.Tables))
|
||||
}
|
||||
|
||||
// Find tables
|
||||
var usersTable, postsTable, commentsTable *models.Table
|
||||
for _, table := range publicSchema.Tables {
|
||||
switch table.Name {
|
||||
case "users":
|
||||
usersTable = table
|
||||
case "posts":
|
||||
postsTable = table
|
||||
case "comments":
|
||||
commentsTable = table
|
||||
}
|
||||
}
|
||||
|
||||
if usersTable == nil {
|
||||
t.Fatal("Users table not found")
|
||||
}
|
||||
if postsTable == nil {
|
||||
t.Fatal("Posts table not found")
|
||||
}
|
||||
if commentsTable == nil {
|
||||
t.Fatal("Comments table not found")
|
||||
}
|
||||
|
||||
// Verify users table has merged columns from 1_users.dbml and 3_add_columns.dbml
|
||||
expectedUserColumns := []string{"id", "email", "name", "created_at"}
|
||||
if len(usersTable.Columns) != len(expectedUserColumns) {
|
||||
t.Errorf("Expected %d columns in users table, got %d", len(expectedUserColumns), len(usersTable.Columns))
|
||||
}
|
||||
|
||||
for _, colName := range expectedUserColumns {
|
||||
if _, exists := usersTable.Columns[colName]; !exists {
|
||||
t.Errorf("Expected column '%s' in users table", colName)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify posts table columns
|
||||
expectedPostColumns := []string{"id", "user_id", "title", "content", "created_at"}
|
||||
for _, colName := range expectedPostColumns {
|
||||
if _, exists := postsTable.Columns[colName]; !exists {
|
||||
t.Errorf("Expected column '%s' in posts table", colName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectory_TableMerging(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
// Find users table
|
||||
var usersTable *models.Table
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == "users" && schema.Name == "public" {
|
||||
usersTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usersTable == nil {
|
||||
t.Fatal("Users table not found")
|
||||
}
|
||||
|
||||
// Verify columns from file 1 (id, email)
|
||||
if _, exists := usersTable.Columns["id"]; !exists {
|
||||
t.Error("Column 'id' from 1_users.dbml not found")
|
||||
}
|
||||
if _, exists := usersTable.Columns["email"]; !exists {
|
||||
t.Error("Column 'email' from 1_users.dbml not found")
|
||||
}
|
||||
|
||||
// Verify columns from file 3 (name, created_at)
|
||||
if _, exists := usersTable.Columns["name"]; !exists {
|
||||
t.Error("Column 'name' from 3_add_columns.dbml not found")
|
||||
}
|
||||
if _, exists := usersTable.Columns["created_at"]; !exists {
|
||||
t.Error("Column 'created_at' from 3_add_columns.dbml not found")
|
||||
}
|
||||
|
||||
// Verify column properties from file 1
|
||||
emailCol := usersTable.Columns["email"]
|
||||
if !emailCol.NotNull {
|
||||
t.Error("Email column should be not null (from 1_users.dbml)")
|
||||
}
|
||||
if emailCol.Type != "varchar(255)" {
|
||||
t.Errorf("Expected email type 'varchar(255)', got '%s'", emailCol.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectory_CommentedRefsLast(t *testing.T) {
|
||||
// This test verifies that files with commented refs are processed last
|
||||
// by checking that the file discovery returns them in the correct order
|
||||
dirPath := filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile")
|
||||
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: dirPath,
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
files, err := reader.discoverDBMLFiles(dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("discoverDBMLFiles() error = %v", err)
|
||||
}
|
||||
|
||||
if len(files) < 2 {
|
||||
t.Skip("Not enough files to test ordering")
|
||||
}
|
||||
|
||||
// Check that 9_refs.dbml (which has commented refs) comes last
|
||||
lastFile := filepath.Base(files[len(files)-1])
|
||||
if lastFile != "9_refs.dbml" {
|
||||
t.Errorf("Expected last file to be '9_refs.dbml' (has commented refs), got '%s'", lastFile)
|
||||
}
|
||||
|
||||
// Check that numbered files without commented refs come first
|
||||
firstFile := filepath.Base(files[0])
|
||||
if firstFile != "1_users.dbml" {
|
||||
t.Errorf("Expected first file to be '1_users.dbml', got '%s'", firstFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectory_EmptyDirectory(t *testing.T) {
|
||||
// Create a temporary empty directory
|
||||
tmpDir := filepath.Join("..", "..", "..", "tests", "assets", "dbml", "empty_test_dir")
|
||||
err := os.MkdirAll(tmpDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: tmpDir,
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() should not error on empty directory, got: %v", err)
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
t.Fatal("ReadDatabase() returned nil database")
|
||||
}
|
||||
|
||||
// Empty directory should return empty database
|
||||
if len(db.Schemas) != 0 {
|
||||
t.Errorf("Expected 0 schemas for empty directory, got %d", len(db.Schemas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabase_BackwardCompat(t *testing.T) {
|
||||
// Test that single file loading still works
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "dbml", "simple.dbml"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
t.Fatal("ReadDatabase() returned nil database")
|
||||
}
|
||||
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("Expected at least one schema")
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if len(schema.Tables) != 1 {
|
||||
t.Fatalf("Expected 1 table, got %d", len(schema.Tables))
|
||||
}
|
||||
|
||||
table := schema.Tables[0]
|
||||
if table.Name != "users" {
|
||||
t.Errorf("Expected table name 'users', got '%s'", table.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFilePrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
wantPrefix int
|
||||
wantHas bool
|
||||
}{
|
||||
{"1_schema.dbml", 1, true},
|
||||
{"2_tables.dbml", 2, true},
|
||||
{"10_relationships.dbml", 10, true},
|
||||
{"99_data.dbml", 99, true},
|
||||
{"schema.dbml", 0, false},
|
||||
{"tables_no_prefix.dbml", 0, false},
|
||||
{"/path/to/1_file.dbml", 1, true},
|
||||
{"/path/to/file.dbml", 0, false},
|
||||
{"1-file.dbml", 1, true},
|
||||
{"2-another.dbml", 2, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
gotPrefix, gotHas := parseFilePrefix(tt.filename)
|
||||
if gotPrefix != tt.wantPrefix {
|
||||
t.Errorf("parseFilePrefix(%s) prefix = %d, want %d", tt.filename, gotPrefix, tt.wantPrefix)
|
||||
}
|
||||
if gotHas != tt.wantHas {
|
||||
t.Errorf("parseFilePrefix(%s) hasPrefix = %v, want %v", tt.filename, gotHas, tt.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasCommentedRefs(t *testing.T) {
|
||||
// Test with the actual multifile test fixtures
|
||||
tests := []struct {
|
||||
filename string
|
||||
wantHas bool
|
||||
}{
|
||||
{filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile", "1_users.dbml"), false},
|
||||
{filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile", "2_posts.dbml"), false},
|
||||
{filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile", "3_add_columns.dbml"), false},
|
||||
{filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile", "9_refs.dbml"), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(filepath.Base(tt.filename), func(t *testing.T) {
|
||||
gotHas, err := hasCommentedRefs(tt.filename)
|
||||
if err != nil {
|
||||
t.Fatalf("hasCommentedRefs() error = %v", err)
|
||||
}
|
||||
if gotHas != tt.wantHas {
|
||||
t.Errorf("hasCommentedRefs(%s) = %v, want %v", filepath.Base(tt.filename), gotHas, tt.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ func (r *Reader) convertToDatabase(dctx *models.DCTXDictionary) (*models.Databas
|
||||
db := models.InitDatabase(dbName)
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Note: DCTX doesn't have database GUID, but schema can use dictionary name if available
|
||||
|
||||
// Create GUID mappings for tables and keys
|
||||
tableGuidMap := make(map[string]string) // GUID -> table name
|
||||
keyGuidMap := make(map[string]*models.DCTXKey) // GUID -> key definition
|
||||
@@ -162,6 +164,10 @@ func (r *Reader) convertTable(dctxTable *models.DCTXTable) (*models.Table, map[s
|
||||
tableName := r.sanitizeName(dctxTable.Name)
|
||||
table := models.InitTable(tableName, "public")
|
||||
table.Description = dctxTable.Description
|
||||
// Assign GUID from DCTX table
|
||||
if dctxTable.Guid != "" {
|
||||
table.GUID = dctxTable.Guid
|
||||
}
|
||||
|
||||
fieldGuidMap := make(map[string]string)
|
||||
|
||||
@@ -202,6 +208,10 @@ func (r *Reader) convertField(dctxField *models.DCTXField, tableName string) ([]
|
||||
|
||||
// Convert single field
|
||||
column := models.InitColumn(r.sanitizeName(dctxField.Name), tableName, "public")
|
||||
// Assign GUID from DCTX field
|
||||
if dctxField.Guid != "" {
|
||||
column.GUID = dctxField.Guid
|
||||
}
|
||||
|
||||
// Map Clarion data types
|
||||
dataType, length := r.mapDataType(dctxField.DataType, dctxField.Size)
|
||||
@@ -346,6 +356,10 @@ func (r *Reader) convertKey(dctxKey *models.DCTXKey, table *models.Table, fieldG
|
||||
constraint.Table = table.Name
|
||||
constraint.Schema = table.Schema
|
||||
constraint.Columns = columns
|
||||
// Assign GUID from DCTX key
|
||||
if dctxKey.Guid != "" {
|
||||
constraint.GUID = dctxKey.Guid
|
||||
}
|
||||
|
||||
table.Constraints[constraint.Name] = constraint
|
||||
|
||||
@@ -366,6 +380,10 @@ func (r *Reader) convertKey(dctxKey *models.DCTXKey, table *models.Table, fieldG
|
||||
index.Columns = columns
|
||||
index.Unique = dctxKey.Unique
|
||||
index.Type = "btree"
|
||||
// Assign GUID from DCTX key
|
||||
if dctxKey.Guid != "" {
|
||||
index.GUID = dctxKey.Guid
|
||||
}
|
||||
|
||||
table.Indexes[index.Name] = index
|
||||
return nil
|
||||
@@ -460,6 +478,10 @@ func (r *Reader) processRelations(dctx *models.DCTXDictionary, schema *models.Sc
|
||||
constraint.ReferencedColumns = pkColumns
|
||||
constraint.OnDelete = r.mapReferentialAction(relation.Delete)
|
||||
constraint.OnUpdate = r.mapReferentialAction(relation.Update)
|
||||
// Assign GUID from DCTX relation
|
||||
if relation.Guid != "" {
|
||||
constraint.GUID = relation.Guid
|
||||
}
|
||||
|
||||
foreignTable.Constraints[fkName] = constraint
|
||||
|
||||
@@ -473,6 +495,10 @@ func (r *Reader) processRelations(dctx *models.DCTXDictionary, schema *models.Sc
|
||||
relationship.ForeignKey = fkName
|
||||
relationship.Properties["on_delete"] = constraint.OnDelete
|
||||
relationship.Properties["on_update"] = constraint.OnUpdate
|
||||
// Assign GUID from DCTX relation
|
||||
if relation.Guid != "" {
|
||||
relationship.GUID = relation.Guid
|
||||
}
|
||||
|
||||
foreignTable.Relationships[relationshipName] = relationship
|
||||
}
|
||||
|
||||
@@ -241,11 +241,9 @@ func (r *Reader) parsePgEnum(line string, matches []string) *models.Enum {
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Enum{
|
||||
Name: enumName,
|
||||
Values: values,
|
||||
Schema: "public",
|
||||
}
|
||||
enum := models.InitEnum(enumName, "public")
|
||||
enum.Values = values
|
||||
return enum
|
||||
}
|
||||
|
||||
// parseTableBlock parses a complete pgTable definition block
|
||||
|
||||
@@ -260,11 +260,7 @@ func (r *Reader) parseType(typeName string, lines []string, schema *models.Schem
|
||||
}
|
||||
|
||||
func (r *Reader) parseEnum(enumName string, lines []string, schema *models.Schema) {
|
||||
enum := &models.Enum{
|
||||
Name: enumName,
|
||||
Schema: schema.Name,
|
||||
Values: make([]string, 0),
|
||||
}
|
||||
enum := models.InitEnum(enumName, schema.Name)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
@@ -329,10 +329,10 @@ func (r *Reader) deriveRelationship(table *models.Table, fk *models.Constraint)
|
||||
relationshipName := fmt.Sprintf("%s_to_%s", table.Name, fk.ReferencedTable)
|
||||
|
||||
relationship := models.InitRelationship(relationshipName, models.OneToMany)
|
||||
relationship.FromTable = fk.ReferencedTable
|
||||
relationship.FromSchema = fk.ReferencedSchema
|
||||
relationship.ToTable = table.Name
|
||||
relationship.ToSchema = table.Schema
|
||||
relationship.FromTable = table.Name
|
||||
relationship.FromSchema = table.Schema
|
||||
relationship.ToTable = fk.ReferencedTable
|
||||
relationship.ToSchema = fk.ReferencedSchema
|
||||
relationship.ForeignKey = fk.Name
|
||||
|
||||
// Store constraint actions in properties
|
||||
|
||||
@@ -328,12 +328,12 @@ func TestDeriveRelationship(t *testing.T) {
|
||||
t.Errorf("Expected relationship type %s, got %s", models.OneToMany, rel.Type)
|
||||
}
|
||||
|
||||
if rel.FromTable != "users" {
|
||||
t.Errorf("Expected FromTable 'users', got '%s'", rel.FromTable)
|
||||
if rel.FromTable != "orders" {
|
||||
t.Errorf("Expected FromTable 'orders', got '%s'", rel.FromTable)
|
||||
}
|
||||
|
||||
if rel.ToTable != "orders" {
|
||||
t.Errorf("Expected ToTable 'orders', got '%s'", rel.ToTable)
|
||||
if rel.ToTable != "users" {
|
||||
t.Errorf("Expected ToTable 'users', got '%s'", rel.ToTable)
|
||||
}
|
||||
|
||||
if rel.ForeignKey != "fk_orders_user_id" {
|
||||
|
||||
@@ -128,11 +128,7 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
if matches := enumRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentBlock = "enum"
|
||||
enumName := matches[1]
|
||||
currentEnum = &models.Enum{
|
||||
Name: enumName,
|
||||
Schema: "public",
|
||||
Values: make([]string, 0),
|
||||
}
|
||||
currentEnum = models.InitEnum(enumName, "public")
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ fmt.Printf("Found %d scripts\n", len(schema.Scripts))
|
||||
## Features
|
||||
|
||||
- **Recursive Directory Scanning**: Automatically scans all subdirectories
|
||||
- **Symlink Skipping**: Symbolic links are automatically skipped (prevents loops and duplicates)
|
||||
- **Multiple Extensions**: Supports both `.sql` and `.pgsql` files
|
||||
- **Flexible Naming**: Extract metadata from filename patterns
|
||||
- **Error Handling**: Validates directory existence and file accessibility
|
||||
@@ -153,8 +154,9 @@ go test ./pkg/readers/sqldir/
|
||||
```
|
||||
|
||||
Tests include:
|
||||
- Valid file parsing
|
||||
- Valid file parsing (underscore and hyphen formats)
|
||||
- Recursive directory scanning
|
||||
- Symlink skipping
|
||||
- Invalid filename handling
|
||||
- Empty directory handling
|
||||
- Error conditions
|
||||
|
||||
@@ -107,11 +107,20 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
// Don't process directories as files (WalkDir still descends into them recursively)
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip symlinks
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get filename
|
||||
filename := d.Name()
|
||||
|
||||
@@ -150,13 +159,11 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
||||
}
|
||||
|
||||
// Create Script model
|
||||
script := &models.Script{
|
||||
Name: name,
|
||||
Description: fmt.Sprintf("SQL script from %s", relPath),
|
||||
SQL: string(content),
|
||||
Priority: priority,
|
||||
Sequence: uint(sequence),
|
||||
}
|
||||
script := models.InitScript(name)
|
||||
script.Description = fmt.Sprintf("SQL script from %s", relPath)
|
||||
script.SQL = string(content)
|
||||
script.Priority = priority
|
||||
script.Sequence = uint(sequence)
|
||||
|
||||
scripts = append(scripts, script)
|
||||
|
||||
|
||||
@@ -373,3 +373,65 @@ func TestReader_MixedFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_SkipSymlinks(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-symlink-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a real SQL file
|
||||
realFile := filepath.Join(tempDir, "1_001_real_file.sql")
|
||||
if err := os.WriteFile(realFile, []byte("SELECT 1;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create real file: %v", err)
|
||||
}
|
||||
|
||||
// Create another file to link to
|
||||
targetFile := filepath.Join(tempDir, "2_001_target.sql")
|
||||
if err := os.WriteFile(targetFile, []byte("SELECT 2;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create target file: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink to the target file (this should be skipped)
|
||||
symlinkFile := filepath.Join(tempDir, "3_001_symlink.sql")
|
||||
if err := os.Symlink(targetFile, symlinkFile); err != nil {
|
||||
// Skip test on systems that don't support symlinks (e.g., Windows without admin)
|
||||
t.Skipf("Symlink creation not supported: %v", err)
|
||||
}
|
||||
|
||||
// Create reader
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
})
|
||||
|
||||
// Read database
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
|
||||
// Should only have 2 scripts (real_file and target), symlink should be skipped
|
||||
if len(schema.Scripts) != 2 {
|
||||
t.Errorf("Expected 2 scripts (symlink should be skipped), got %d", len(schema.Scripts))
|
||||
}
|
||||
|
||||
// Verify the scripts are the real files, not the symlink
|
||||
scriptNames := make(map[string]bool)
|
||||
for _, script := range schema.Scripts {
|
||||
scriptNames[script.Name] = true
|
||||
}
|
||||
|
||||
if !scriptNames["real_file"] {
|
||||
t.Error("Expected 'real_file' script to be present")
|
||||
}
|
||||
if !scriptNames["target"] {
|
||||
t.Error("Expected 'target' script to be present")
|
||||
}
|
||||
if scriptNames["symlink"] {
|
||||
t.Error("Symlink script should have been skipped but was found")
|
||||
}
|
||||
}
|
||||
|
||||
95
pkg/ui/column_dataops.go
Normal file
95
pkg/ui/column_dataops.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Column data operations - business logic for column management
|
||||
|
||||
// CreateColumn creates a new column and adds it to a table
|
||||
func (se *SchemaEditor) CreateColumn(schemaIndex, tableIndex int, name, dataType string, isPrimaryKey, isNotNull bool) *models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if table.Columns == nil {
|
||||
table.Columns = make(map[string]*models.Column)
|
||||
}
|
||||
|
||||
newColumn := &models.Column{
|
||||
Name: name,
|
||||
Type: dataType,
|
||||
IsPrimaryKey: isPrimaryKey,
|
||||
NotNull: isNotNull,
|
||||
}
|
||||
table.UpdateDate()
|
||||
table.Columns[name] = newColumn
|
||||
return newColumn
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column's properties
|
||||
func (se *SchemaEditor) UpdateColumn(schemaIndex, tableIndex int, oldName, newName, dataType string, isPrimaryKey, isNotNull bool, defaultValue interface{}, description string) bool {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
column, exists := table.Columns[oldName]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
table.UpdateDate()
|
||||
|
||||
// If name changed, remove old entry and create new one
|
||||
if oldName != newName {
|
||||
delete(table.Columns, oldName)
|
||||
column.Name = newName
|
||||
table.Columns[newName] = column
|
||||
}
|
||||
|
||||
// Update properties
|
||||
column.Type = dataType
|
||||
column.IsPrimaryKey = isPrimaryKey
|
||||
column.NotNull = isNotNull
|
||||
column.Default = defaultValue
|
||||
column.Description = description
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteColumn removes a column from a table
|
||||
func (se *SchemaEditor) DeleteColumn(schemaIndex, tableIndex int, columnName string) bool {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, exists := table.Columns[columnName]; !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
table.UpdateDate()
|
||||
|
||||
delete(table.Columns, columnName)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetColumn returns a column by name
|
||||
func (se *SchemaEditor) GetColumn(schemaIndex, tableIndex int, columnName string) *models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return table.Columns[columnName]
|
||||
}
|
||||
|
||||
// GetAllColumns returns all columns in a table
|
||||
func (se *SchemaEditor) GetAllColumns(schemaIndex, tableIndex int) map[string]*models.Column {
|
||||
table := se.GetTable(schemaIndex, tableIndex)
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return table.Columns
|
||||
}
|
||||
214
pkg/ui/column_screens.go
Normal file
214
pkg/ui/column_screens.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showColumnEditor shows editor for a specific column
|
||||
func (se *SchemaEditor) showColumnEditor(schemaIndex, tableIndex, colIndex int, column *models.Column) {
|
||||
form := tview.NewForm()
|
||||
|
||||
// Store original name to handle renames
|
||||
originalName := column.Name
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := column.Name
|
||||
newType := column.Type
|
||||
newIsPK := column.IsPrimaryKey
|
||||
newIsNotNull := column.NotNull
|
||||
newDefault := column.Default
|
||||
newDescription := column.Description
|
||||
newGUID := column.GUID
|
||||
|
||||
// Column type options: PostgreSQL, MySQL, SQL Server, and common SQL types
|
||||
columnTypes := []string{
|
||||
// Numeric Types
|
||||
"SMALLINT", "INTEGER", "BIGINT", "INT", "TINYINT", "FLOAT", "REAL", "DOUBLE PRECISION",
|
||||
"DECIMAL(10,2)", "NUMERIC", "DECIMAL", "NUMERIC(10,2)",
|
||||
// Character Types
|
||||
"CHAR", "VARCHAR", "VARCHAR(255)", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR(255)",
|
||||
// Boolean
|
||||
"BOOLEAN", "BOOL", "BIT",
|
||||
// Date/Time Types
|
||||
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
|
||||
"DATETIME", "DATETIME2", "DATEFIRST",
|
||||
// UUID and JSON
|
||||
"UUID", "GUID", "JSON", "JSONB",
|
||||
// Binary Types
|
||||
"BYTEA", "BLOB", "IMAGE", "VARBINARY", "VARBINARY(MAX)", "BINARY",
|
||||
// PostgreSQL Special Types
|
||||
"int4range", "int8range", "numrange", "tsrange", "tstzrange", "daterange",
|
||||
"HSTORE", "CITEXT", "INET", "MACADDR", "POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE",
|
||||
// Array Types
|
||||
"INTEGER ARRAY", "VARCHAR ARRAY", "TEXT ARRAY", "BIGINT ARRAY",
|
||||
// MySQL Specific
|
||||
"MEDIUMINT", "DOUBLE", "FLOAT(10,2)",
|
||||
// SQL Server Specific
|
||||
"MONEY", "SMALLMONEY", "SQL_VARIANT",
|
||||
}
|
||||
selectedTypeIndex := 0
|
||||
|
||||
// Add existing type if not already in the list
|
||||
typeExists := false
|
||||
for i, opt := range columnTypes {
|
||||
if opt == column.Type {
|
||||
selectedTypeIndex = i
|
||||
typeExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !typeExists && column.Type != "" {
|
||||
columnTypes = append(columnTypes, column.Type)
|
||||
selectedTypeIndex = len(columnTypes) - 1
|
||||
}
|
||||
|
||||
form.AddInputField("Column Name", column.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Type", columnTypes, selectedTypeIndex, func(option string, index int) {
|
||||
newType = option
|
||||
})
|
||||
|
||||
form.AddCheckbox("Primary Key", column.IsPrimaryKey, func(checked bool) {
|
||||
newIsPK = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Not Null", column.NotNull, func(checked bool) {
|
||||
newIsNotNull = checked
|
||||
})
|
||||
|
||||
defaultStr := ""
|
||||
if column.Default != nil {
|
||||
defaultStr = fmt.Sprintf("%v", column.Default)
|
||||
}
|
||||
form.AddInputField("Default Value", defaultStr, 40, nil, func(value string) {
|
||||
newDefault = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", column.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddInputField("GUID", column.GUID, 40, nil, func(value string) {
|
||||
newGUID = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateColumn(schemaIndex, tableIndex, originalName, newName, newType, newIsPK, newIsNotNull, newDefault, newDescription)
|
||||
se.db.Schemas[schemaIndex].Tables[tableIndex].Columns[newName].GUID = newGUID
|
||||
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
se.showDeleteColumnConfirm(schemaIndex, tableIndex, originalName)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Column ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("column-editor", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("column-editor", form, true, true)
|
||||
}
|
||||
|
||||
// showNewColumnDialog shows dialog to create a new column
|
||||
func (se *SchemaEditor) showNewColumnDialog(schemaIndex, tableIndex int) {
|
||||
form := tview.NewForm()
|
||||
|
||||
columnName := ""
|
||||
dataType := "VARCHAR(255)"
|
||||
|
||||
// Column type options: PostgreSQL, MySQL, SQL Server, and common SQL types
|
||||
columnTypes := []string{
|
||||
// Numeric Types
|
||||
"SMALLINT", "INTEGER", "BIGINT", "INT", "TINYINT", "FLOAT", "REAL", "DOUBLE PRECISION",
|
||||
"DECIMAL(10,2)", "NUMERIC", "DECIMAL", "NUMERIC(10,2)",
|
||||
// Character Types
|
||||
"CHAR", "VARCHAR", "VARCHAR(255)", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR(255)",
|
||||
// Boolean
|
||||
"BOOLEAN", "BOOL", "BIT",
|
||||
// Date/Time Types
|
||||
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
|
||||
"DATETIME", "DATETIME2", "DATEFIRST",
|
||||
// UUID and JSON
|
||||
"UUID", "GUID", "JSON", "JSONB",
|
||||
// Binary Types
|
||||
"BYTEA", "BLOB", "IMAGE", "VARBINARY", "VARBINARY(MAX)", "BINARY",
|
||||
// PostgreSQL Special Types
|
||||
"int4range", "int8range", "numrange", "tsrange", "tstzrange", "daterange",
|
||||
"HSTORE", "CITEXT", "INET", "MACADDR", "POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE",
|
||||
// Array Types
|
||||
"INTEGER ARRAY", "VARCHAR ARRAY", "TEXT ARRAY", "BIGINT ARRAY",
|
||||
// MySQL Specific
|
||||
"MEDIUMINT", "DOUBLE", "FLOAT(10,2)",
|
||||
// SQL Server Specific
|
||||
"MONEY", "SMALLMONEY", "SQL_VARIANT",
|
||||
}
|
||||
selectedTypeIndex := 0
|
||||
|
||||
form.AddInputField("Column Name", "", 40, nil, func(value string) {
|
||||
columnName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Data Type", columnTypes, selectedTypeIndex, func(option string, index int) {
|
||||
dataType = option
|
||||
})
|
||||
|
||||
form.AddCheckbox("Primary Key", false, nil)
|
||||
form.AddCheckbox("Not Null", false, nil)
|
||||
form.AddCheckbox("Unique", false, nil)
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if columnName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get form values
|
||||
isPK := form.GetFormItemByLabel("Primary Key").(*tview.Checkbox).IsChecked()
|
||||
isNotNull := form.GetFormItemByLabel("Not Null").(*tview.Checkbox).IsChecked()
|
||||
|
||||
se.CreateColumn(schemaIndex, tableIndex, columnName, dataType, isPK, isNotNull)
|
||||
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("new-column")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("new-column")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Column ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-column", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-column", form, true, true)
|
||||
}
|
||||
15
pkg/ui/database_dataops.go
Normal file
15
pkg/ui/database_dataops.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// updateDatabase updates database properties
|
||||
func (se *SchemaEditor) updateDatabase(name, description, comment, dbType, dbVersion string) {
|
||||
se.db.Name = name
|
||||
se.db.Description = description
|
||||
se.db.Comment = comment
|
||||
se.db.DatabaseType = models.DatabaseType(dbType)
|
||||
se.db.DatabaseVersion = dbVersion
|
||||
se.db.UpdateDate()
|
||||
}
|
||||
78
pkg/ui/database_screens.go
Normal file
78
pkg/ui/database_screens.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// showEditDatabaseForm displays a dialog to edit database properties
|
||||
func (se *SchemaEditor) showEditDatabaseForm() {
|
||||
form := tview.NewForm()
|
||||
|
||||
dbName := se.db.Name
|
||||
dbDescription := se.db.Description
|
||||
dbComment := se.db.Comment
|
||||
dbType := string(se.db.DatabaseType)
|
||||
dbVersion := se.db.DatabaseVersion
|
||||
dbGUID := se.db.GUID
|
||||
|
||||
// Database type options
|
||||
dbTypeOptions := []string{"pgsql", "mssql", "sqlite"}
|
||||
selectedTypeIndex := 0
|
||||
for i, opt := range dbTypeOptions {
|
||||
if opt == dbType {
|
||||
selectedTypeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form.AddInputField("Database Name", dbName, 40, nil, func(value string) {
|
||||
dbName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", dbDescription, 50, nil, func(value string) {
|
||||
dbDescription = value
|
||||
})
|
||||
|
||||
form.AddInputField("Comment", dbComment, 50, nil, func(value string) {
|
||||
dbComment = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Database Type", dbTypeOptions, selectedTypeIndex, func(option string, index int) {
|
||||
dbType = option
|
||||
})
|
||||
|
||||
form.AddInputField("Database Version", dbVersion, 20, nil, func(value string) {
|
||||
dbVersion = value
|
||||
})
|
||||
|
||||
form.AddInputField("GUID", dbGUID, 40, nil, func(value string) {
|
||||
dbGUID = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if dbName == "" {
|
||||
return
|
||||
}
|
||||
se.updateDatabase(dbName, dbDescription, dbComment, dbType, dbVersion)
|
||||
se.db.GUID = dbGUID
|
||||
se.pages.RemovePage("edit-database")
|
||||
se.pages.RemovePage("main")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("edit-database")
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Database ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-database", "main")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-database", form, true, true)
|
||||
}
|
||||
139
pkg/ui/dialogs.go
Normal file
139
pkg/ui/dialogs.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// showExitConfirmation shows a confirmation dialog when trying to exit without saving
|
||||
func (se *SchemaEditor) showExitConfirmation(pageToRemove, pageToSwitchTo string) {
|
||||
modal := tview.NewModal().
|
||||
SetText("Exit without saving changes?").
|
||||
AddButtons([]string{"Cancel", "No, exit without saving"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "No, exit without saving" {
|
||||
se.pages.RemovePage(pageToRemove)
|
||||
se.pages.SwitchToPage(pageToSwitchTo)
|
||||
}
|
||||
se.pages.RemovePage("exit-confirm")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("exit-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("exit-confirm", modal, true, true)
|
||||
}
|
||||
|
||||
// showExitEditorConfirm shows confirmation dialog when trying to exit the entire editor
|
||||
func (se *SchemaEditor) showExitEditorConfirm() {
|
||||
modal := tview.NewModal().
|
||||
SetText("Exit RelSpec Editor? Press ESC again to confirm.").
|
||||
AddButtons([]string{"Cancel", "Exit"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Exit" {
|
||||
se.app.Stop()
|
||||
}
|
||||
se.pages.RemovePage("exit-editor-confirm")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("exit-editor-confirm", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteSchemaConfirm shows confirmation dialog for schema deletion
|
||||
func (se *SchemaEditor) showDeleteSchemaConfirm(schemaIndex int) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete schema '%s'? This will delete all tables in this schema.",
|
||||
se.db.Schemas[schemaIndex].Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteSchema(schemaIndex)
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
}
|
||||
se.pages.RemovePage("confirm-delete-schema")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-schema")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-schema", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteTableConfirm shows confirmation dialog for table deletion
|
||||
func (se *SchemaEditor) showDeleteTableConfirm(schemaIndex, tableIndex int) {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete table '%s'? This action cannot be undone.",
|
||||
table.Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteTable(schemaIndex, tableIndex)
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
}
|
||||
se.pages.RemovePage("confirm-delete-table")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-table")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-table", modal, true, true)
|
||||
}
|
||||
|
||||
// showDeleteColumnConfirm shows confirmation dialog for column deletion
|
||||
func (se *SchemaEditor) showDeleteColumnConfirm(schemaIndex, tableIndex int, columnName string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete column '%s'? This action cannot be undone.",
|
||||
columnName)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.DeleteColumn(schemaIndex, tableIndex, columnName)
|
||||
se.pages.RemovePage("column-editor")
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
se.pages.SwitchToPage("table-editor")
|
||||
} else {
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("confirm-delete-column")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("confirm-delete-column", modal, true, true)
|
||||
}
|
||||
35
pkg/ui/domain_dataops.go
Normal file
35
pkg/ui/domain_dataops.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// createDomain creates a new domain
|
||||
func (se *SchemaEditor) createDomain(name, description string) {
|
||||
domain := &models.Domain{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Tables: make([]*models.DomainTable, 0),
|
||||
Sequence: uint(len(se.db.Domains)),
|
||||
}
|
||||
|
||||
se.db.Domains = append(se.db.Domains, domain)
|
||||
se.showDomainList()
|
||||
}
|
||||
|
||||
// updateDomain updates an existing domain
|
||||
func (se *SchemaEditor) updateDomain(index int, name, description string) {
|
||||
if index >= 0 && index < len(se.db.Domains) {
|
||||
se.db.Domains[index].Name = name
|
||||
se.db.Domains[index].Description = description
|
||||
se.showDomainList()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDomain deletes a domain by index
|
||||
func (se *SchemaEditor) deleteDomain(index int) {
|
||||
if index >= 0 && index < len(se.db.Domains) {
|
||||
se.db.Domains = append(se.db.Domains[:index], se.db.Domains[index+1:]...)
|
||||
se.showDomainList()
|
||||
}
|
||||
}
|
||||
258
pkg/ui/domain_screens.go
Normal file
258
pkg/ui/domain_screens.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showDomainList displays the domain management screen
|
||||
func (se *SchemaEditor) showDomainList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Manage Domains").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create domains table
|
||||
domainTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row
|
||||
headers := []string{"Name", "Sequence", "Total Tables", "Description"}
|
||||
headerWidths := []int{20, 15, 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)
|
||||
domainTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Add existing domains
|
||||
for row, domain := range se.db.Domains {
|
||||
domain := domain // capture for closure
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", domain.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Sequence - pad to 15 chars
|
||||
seqStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", domain.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 1, seqCell)
|
||||
|
||||
// Total Tables - pad to 20 chars
|
||||
tablesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(domain.Tables)))
|
||||
tablesCell := tview.NewTableCell(tablesStr).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 2, tablesCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(domain.Description).SetSelectable(true)
|
||||
domainTable.SetCell(row+1, 3, descCell)
|
||||
}
|
||||
|
||||
domainTable.SetTitle(" Domains ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewDomain := tview.NewButton("New Domain [n]").SetSelectedFunc(func() {
|
||||
se.showNewDomainDialog()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewDomain.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(domainTable)
|
||||
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(btnNewDomain)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(domainTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewDomain, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
domainTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewDomain)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := domainTable.GetSelection()
|
||||
if row > 0 && row <= len(se.db.Domains) { // Skip header row
|
||||
domainIndex := row - 1
|
||||
se.showDomainEditor(domainIndex, se.db.Domains[domainIndex])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewDomainDialog()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(domainTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("domains", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewDomainDialog displays a dialog to create a new domain
|
||||
func (se *SchemaEditor) showNewDomainDialog() {
|
||||
form := tview.NewForm()
|
||||
|
||||
domainName := ""
|
||||
domainDesc := ""
|
||||
|
||||
form.AddInputField("Name", "", 40, nil, func(value string) {
|
||||
domainName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 50, nil, func(value string) {
|
||||
domainDesc = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if domainName == "" {
|
||||
return
|
||||
}
|
||||
se.createDomain(domainName, domainDesc)
|
||||
se.pages.RemovePage("new-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Domain ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-domain", "domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-domain", form, true, true)
|
||||
}
|
||||
|
||||
// showDomainEditor displays a dialog to edit an existing domain
|
||||
func (se *SchemaEditor) showDomainEditor(index int, domain *models.Domain) {
|
||||
form := tview.NewForm()
|
||||
|
||||
domainName := domain.Name
|
||||
domainDesc := domain.Description
|
||||
|
||||
form.AddInputField("Name", domainName, 40, nil, func(value string) {
|
||||
domainName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", domainDesc, 50, nil, func(value string) {
|
||||
domainDesc = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if domainName == "" {
|
||||
return
|
||||
}
|
||||
se.updateDomain(index, domainName, domainDesc)
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
se.showDeleteDomainConfirm(index)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Domain ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-domain", "domains")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-domain", form, true, true)
|
||||
}
|
||||
|
||||
// showDeleteDomainConfirm shows a confirmation dialog before deleting a domain
|
||||
func (se *SchemaEditor) showDeleteDomainConfirm(index int) {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete domain '%s'? This action cannot be undone.", se.db.Domains[index].Name)).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Delete" {
|
||||
se.deleteDomain(index)
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
se.pages.RemovePage("edit-domain")
|
||||
se.pages.RemovePage("domains")
|
||||
se.showDomainList()
|
||||
} else {
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("delete-domain-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddAndSwitchToPage("delete-domain-confirm", modal, true)
|
||||
}
|
||||
73
pkg/ui/editor.go
Normal file
73
pkg/ui/editor.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// SchemaEditor represents the interactive schema editor
|
||||
type SchemaEditor struct {
|
||||
db *models.Database
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
loadConfig *LoadConfig
|
||||
saveConfig *SaveConfig
|
||||
}
|
||||
|
||||
// NewSchemaEditor creates a new schema editor
|
||||
func NewSchemaEditor(db *models.Database) *SchemaEditor {
|
||||
return &SchemaEditor{
|
||||
db: db,
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
loadConfig: nil,
|
||||
saveConfig: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSchemaEditorWithConfigs creates a new schema editor with load/save configurations
|
||||
func NewSchemaEditorWithConfigs(db *models.Database, loadConfig *LoadConfig, saveConfig *SaveConfig) *SchemaEditor {
|
||||
return &SchemaEditor{
|
||||
db: db,
|
||||
app: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
loadConfig: loadConfig,
|
||||
saveConfig: saveConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the interactive editor
|
||||
func (se *SchemaEditor) Run() error {
|
||||
// If no database is loaded, show load screen
|
||||
if se.db == nil {
|
||||
se.showLoadScreen()
|
||||
} else {
|
||||
// Create main menu view
|
||||
mainMenu := se.createMainMenu()
|
||||
se.pages.AddPage("main", mainMenu, true, true)
|
||||
}
|
||||
|
||||
// Run the application
|
||||
if err := se.app.SetRoot(se.pages, true).Run(); err != nil {
|
||||
return fmt.Errorf("application error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatabase returns the current database
|
||||
func (se *SchemaEditor) GetDatabase() *models.Database {
|
||||
return se.db
|
||||
}
|
||||
|
||||
// Helper function to get sorted column names
|
||||
func getColumnNames(table *models.Table) []string {
|
||||
names := make([]string, 0, len(table.Columns))
|
||||
for name := range table.Columns {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
791
pkg/ui/load_save_screens.go
Normal file
791
pkg/ui/load_save_screens.go
Normal file
@@ -0,0 +1,791 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/merge"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
rbun "git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
rdbml "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
rdctx "git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
rdrawdb "git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
rdrizzle "git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
rgorm "git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
rgraphql "git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
rjson "git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
rpgsql "git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
rprisma "git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
rtypeorm "git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
ryaml "git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
wdctx "git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
wdrawdb "git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
wdrizzle "git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
// LoadConfig holds the configuration for loading a database
|
||||
type LoadConfig struct {
|
||||
SourceType string
|
||||
FilePath string
|
||||
ConnString string
|
||||
}
|
||||
|
||||
// SaveConfig holds the configuration for saving a database
|
||||
type SaveConfig struct {
|
||||
TargetType string
|
||||
FilePath string
|
||||
ConnString string
|
||||
}
|
||||
|
||||
// showLoadScreen displays the database load screen
|
||||
func (se *SchemaEditor) showLoadScreen() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Load Database Schema").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Form
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Load Configuration ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Format selection
|
||||
formatOptions := []string{
|
||||
"dbml", "dctx", "drawdb", "graphql", "json", "yaml",
|
||||
"gorm", "bun", "drizzle", "prisma", "typeorm", "pgsql",
|
||||
}
|
||||
selectedFormat := 0
|
||||
currentFormat := formatOptions[selectedFormat]
|
||||
|
||||
// File path input
|
||||
filePath := ""
|
||||
connString := ""
|
||||
|
||||
form.AddDropDown("Format", formatOptions, 0, func(option string, index int) {
|
||||
selectedFormat = index
|
||||
currentFormat = option
|
||||
})
|
||||
|
||||
form.AddInputField("File Path", "", 50, nil, func(value string) {
|
||||
filePath = value
|
||||
})
|
||||
|
||||
form.AddInputField("Connection String", "", 50, nil, func(value string) {
|
||||
connString = value
|
||||
})
|
||||
|
||||
form.AddTextView("Help", getLoadHelpText(), 0, 5, true, false)
|
||||
|
||||
// Buttons
|
||||
form.AddButton("Load [l]", func() {
|
||||
se.loadDatabase(currentFormat, filePath, connString)
|
||||
})
|
||||
|
||||
form.AddButton("Create New [n]", func() {
|
||||
se.createNewDatabase()
|
||||
})
|
||||
|
||||
form.AddButton("Exit [q]", func() {
|
||||
se.app.Stop()
|
||||
})
|
||||
|
||||
// Keyboard shortcuts
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
switch event.Rune() {
|
||||
case 'l':
|
||||
se.loadDatabase(currentFormat, filePath, connString)
|
||||
return nil
|
||||
case 'n':
|
||||
se.createNewDatabase()
|
||||
return nil
|
||||
case 'q':
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
// Tab navigation
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'l' || event.Rune() == 'n' || event.Rune() == 'q' {
|
||||
return event
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true)
|
||||
|
||||
se.pages.AddAndSwitchToPage("load-database", flex, true)
|
||||
}
|
||||
|
||||
// showSaveScreen displays the save database screen
|
||||
func (se *SchemaEditor) showSaveScreen() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Save Database Schema").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Form
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Save Configuration ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Format selection
|
||||
formatOptions := []string{
|
||||
"dbml", "dctx", "drawdb", "graphql", "json", "yaml",
|
||||
"gorm", "bun", "drizzle", "prisma", "typeorm", "pgsql",
|
||||
}
|
||||
selectedFormat := 0
|
||||
currentFormat := formatOptions[selectedFormat]
|
||||
|
||||
// File path input
|
||||
filePath := ""
|
||||
if se.saveConfig != nil {
|
||||
// Pre-populate with existing save config
|
||||
for i, format := range formatOptions {
|
||||
if format == se.saveConfig.TargetType {
|
||||
selectedFormat = i
|
||||
currentFormat = format
|
||||
break
|
||||
}
|
||||
}
|
||||
filePath = se.saveConfig.FilePath
|
||||
}
|
||||
|
||||
form.AddDropDown("Format", formatOptions, selectedFormat, func(option string, index int) {
|
||||
selectedFormat = index
|
||||
currentFormat = option
|
||||
})
|
||||
|
||||
form.AddInputField("File Path", filePath, 50, nil, func(value string) {
|
||||
filePath = value
|
||||
})
|
||||
|
||||
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
|
||||
|
||||
// Buttons
|
||||
form.AddButton("Save [s]", func() {
|
||||
se.saveDatabase(currentFormat, filePath)
|
||||
})
|
||||
|
||||
form.AddButton("Update Existing Database [u]", func() {
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
if se.saveConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else if se.loadConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else {
|
||||
se.showErrorDialog("Error", "No database source found. Use Save instead.")
|
||||
}
|
||||
})
|
||||
|
||||
form.AddButton("Back [b]", func() {
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
})
|
||||
|
||||
// Keyboard shortcuts
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
}
|
||||
switch event.Rune() {
|
||||
case 's':
|
||||
se.saveDatabase(currentFormat, filePath)
|
||||
return nil
|
||||
case 'u':
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
if se.saveConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else if se.loadConfig != nil {
|
||||
se.showUpdateExistingDatabaseConfirm()
|
||||
} else {
|
||||
se.showErrorDialog("Error", "No database source found. Use Save instead.")
|
||||
}
|
||||
return nil
|
||||
case 'b':
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true)
|
||||
|
||||
se.pages.AddAndSwitchToPage("save-database", flex, true)
|
||||
}
|
||||
|
||||
// loadDatabase loads a database from the specified configuration
|
||||
func (se *SchemaEditor) loadDatabase(format, filePath, connString string) {
|
||||
// Validate input
|
||||
if format == "pgsql" {
|
||||
if connString == "" {
|
||||
se.showErrorDialog("Error", "Connection string is required for PostgreSQL")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if filePath == "" {
|
||||
se.showErrorDialog("Error", "File path is required for "+format)
|
||||
return
|
||||
}
|
||||
// Expand home directory
|
||||
if len(filePath) > 0 && filePath[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
filePath = filepath.Join(home, filePath[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create reader
|
||||
var reader readers.Reader
|
||||
switch format {
|
||||
case "dbml":
|
||||
reader = rdbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
reader = rdctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
reader = rdrawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
reader = rgraphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
reader = rjson.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
reader = ryaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
reader = rgorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
reader = rbun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
reader = rdrizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
reader = rprisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
reader = rtypeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
reader = rpgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
se.showErrorDialog("Error", "Unsupported format: "+format)
|
||||
return
|
||||
}
|
||||
|
||||
// Read database
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
se.showErrorDialog("Load Error", fmt.Sprintf("Failed to load database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Store load config
|
||||
se.loadConfig = &LoadConfig{
|
||||
SourceType: format,
|
||||
FilePath: filePath,
|
||||
ConnString: connString,
|
||||
}
|
||||
|
||||
// Update database
|
||||
se.db = db
|
||||
|
||||
// Show success and switch to main menu
|
||||
se.showSuccessDialog("Load Complete", fmt.Sprintf("Successfully loaded database '%s'", db.Name), func() {
|
||||
se.pages.RemovePage("load-database")
|
||||
se.pages.RemovePage("main")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// saveDatabase saves the database to the specified configuration
|
||||
func (se *SchemaEditor) saveDatabase(format, filePath string) {
|
||||
// Validate input
|
||||
if format == "pgsql" {
|
||||
se.showErrorDialog("Error", "Direct PostgreSQL save is not supported from the UI. Use --to pgsql --to-path output.sql")
|
||||
return
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
se.showErrorDialog("Error", "File path is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Expand home directory
|
||||
if len(filePath) > 0 && filePath[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
filePath = filepath.Join(home, filePath[1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Create writer
|
||||
var writer writers.Writer
|
||||
switch format {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
default:
|
||||
se.showErrorDialog("Error", "Unsupported format: "+format)
|
||||
return
|
||||
}
|
||||
|
||||
// Write database
|
||||
err := writer.WriteDatabase(se.db)
|
||||
if err != nil {
|
||||
se.showErrorDialog("Save Error", fmt.Sprintf("Failed to save database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Store save config
|
||||
se.saveConfig = &SaveConfig{
|
||||
TargetType: format,
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
// Show success
|
||||
se.showSuccessDialog("Save Complete", fmt.Sprintf("Successfully saved database to %s", filePath), func() {
|
||||
se.pages.RemovePage("save-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
})
|
||||
}
|
||||
|
||||
// createNewDatabase creates a new empty database
|
||||
func (se *SchemaEditor) createNewDatabase() {
|
||||
// Create a new empty database
|
||||
se.db = &models.Database{
|
||||
Name: "New Database",
|
||||
Schemas: []*models.Schema{},
|
||||
}
|
||||
|
||||
// Clear load config
|
||||
se.loadConfig = nil
|
||||
|
||||
// Show success and switch to main menu
|
||||
se.showSuccessDialog("New Database", "Created new empty database", func() {
|
||||
se.pages.RemovePage("load-database")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// showErrorDialog displays an error dialog
|
||||
func (se *SchemaEditor) showErrorDialog(_title, message string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(message).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
se.pages.RemovePage("error-dialog")
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("error-dialog")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("error-dialog", modal, true, true)
|
||||
}
|
||||
|
||||
// showSuccessDialog displays a success dialog
|
||||
func (se *SchemaEditor) showSuccessDialog(_title, message string, onClose func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(message).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
se.pages.RemovePage("success-dialog")
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("success-dialog")
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("success-dialog", modal, true, true)
|
||||
}
|
||||
|
||||
// getLoadHelpText returns the help text for the load screen
|
||||
func getLoadHelpText() string {
|
||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
|
||||
Database formats: pgsql (requires connection string)
|
||||
|
||||
Examples:
|
||||
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
|
||||
- Connection: postgres://user:pass@localhost/dbname`
|
||||
}
|
||||
|
||||
// showUpdateExistingDatabaseConfirm displays a confirmation dialog before updating existing database
|
||||
func (se *SchemaEditor) showUpdateExistingDatabaseConfirm() {
|
||||
// Use saveConfig if available, otherwise use loadConfig
|
||||
var targetType, targetPath string
|
||||
if se.saveConfig != nil {
|
||||
targetType = se.saveConfig.TargetType
|
||||
targetPath = se.saveConfig.FilePath
|
||||
} else if se.loadConfig != nil {
|
||||
targetType = se.loadConfig.SourceType
|
||||
targetPath = se.loadConfig.FilePath
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
confirmText := fmt.Sprintf("Update existing database?\n\nFormat: %s\nPath: %s\n\nThis will overwrite the source.",
|
||||
targetType, targetPath)
|
||||
|
||||
modal := tview.NewModal().
|
||||
SetText(confirmText).
|
||||
AddButtons([]string{"Cancel", "Update"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Update" {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
se.pages.RemovePage("save-database")
|
||||
se.saveDatabase(targetType, targetPath)
|
||||
se.pages.SwitchToPage("main")
|
||||
} else {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("update-confirm")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddAndSwitchToPage("update-confirm", modal, true)
|
||||
}
|
||||
|
||||
// getSaveHelpText returns the help text for the save screen
|
||||
func getSaveHelpText() string {
|
||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql (SQL export)
|
||||
|
||||
Examples:
|
||||
- File: ~/schemas/mydb.dbml
|
||||
- Directory (for code formats): ./models/`
|
||||
}
|
||||
|
||||
// showImportScreen displays the import/merge database screen
|
||||
func (se *SchemaEditor) showImportScreen() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Import & Merge Database Schema").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Form
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Import Configuration ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Format selection
|
||||
formatOptions := []string{
|
||||
"dbml", "dctx", "drawdb", "graphql", "json", "yaml",
|
||||
"gorm", "bun", "drizzle", "prisma", "typeorm", "pgsql",
|
||||
}
|
||||
selectedFormat := 0
|
||||
currentFormat := formatOptions[selectedFormat]
|
||||
|
||||
// File path input
|
||||
filePath := ""
|
||||
connString := ""
|
||||
skipDomains := false
|
||||
skipRelations := false
|
||||
skipEnums := false
|
||||
skipViews := false
|
||||
skipSequences := false
|
||||
skipTables := ""
|
||||
|
||||
form.AddDropDown("Format", formatOptions, 0, func(option string, index int) {
|
||||
selectedFormat = index
|
||||
currentFormat = option
|
||||
})
|
||||
|
||||
form.AddInputField("File Path", "", 50, nil, func(value string) {
|
||||
filePath = value
|
||||
})
|
||||
|
||||
form.AddInputField("Connection String", "", 50, nil, func(value string) {
|
||||
connString = value
|
||||
})
|
||||
|
||||
form.AddInputField("Skip Tables (comma-separated)", "", 50, nil, func(value string) {
|
||||
skipTables = value
|
||||
})
|
||||
|
||||
form.AddCheckbox("Skip Domains", false, func(checked bool) {
|
||||
skipDomains = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Skip Relations", false, func(checked bool) {
|
||||
skipRelations = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Skip Enums", false, func(checked bool) {
|
||||
skipEnums = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Skip Views", false, func(checked bool) {
|
||||
skipViews = checked
|
||||
})
|
||||
|
||||
form.AddCheckbox("Skip Sequences", false, func(checked bool) {
|
||||
skipSequences = checked
|
||||
})
|
||||
|
||||
form.AddTextView("Help", getImportHelpText(), 0, 7, true, false)
|
||||
|
||||
// Buttons
|
||||
form.AddButton("Import & Merge [i]", func() {
|
||||
se.importAndMergeDatabase(currentFormat, filePath, connString, skipDomains, skipRelations, skipEnums, skipViews, skipSequences, skipTables)
|
||||
})
|
||||
|
||||
form.AddButton("Back [b]", func() {
|
||||
se.pages.RemovePage("import-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
})
|
||||
|
||||
form.AddButton("Exit [q]", func() {
|
||||
se.app.Stop()
|
||||
})
|
||||
|
||||
// Keyboard shortcuts
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("import-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
}
|
||||
switch event.Rune() {
|
||||
case 'i':
|
||||
se.importAndMergeDatabase(currentFormat, filePath, connString, skipDomains, skipRelations, skipEnums, skipViews, skipSequences, skipTables)
|
||||
return nil
|
||||
case 'b':
|
||||
se.pages.RemovePage("import-database")
|
||||
se.pages.SwitchToPage("main")
|
||||
return nil
|
||||
case 'q':
|
||||
se.app.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true)
|
||||
|
||||
se.pages.AddAndSwitchToPage("import-database", flex, true)
|
||||
}
|
||||
|
||||
// importAndMergeDatabase imports and merges a database from the specified configuration
|
||||
func (se *SchemaEditor) importAndMergeDatabase(format, filePath, connString string, skipDomains, skipRelations, skipEnums, skipViews, skipSequences bool, skipTables string) {
|
||||
// Validate input
|
||||
if format == "pgsql" {
|
||||
if connString == "" {
|
||||
se.showErrorDialog("Error", "Connection string is required for PostgreSQL")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if filePath == "" {
|
||||
se.showErrorDialog("Error", "File path is required for "+format)
|
||||
return
|
||||
}
|
||||
// Expand home directory
|
||||
if len(filePath) > 0 && filePath[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
filePath = filepath.Join(home, filePath[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create reader
|
||||
var reader readers.Reader
|
||||
switch format {
|
||||
case "dbml":
|
||||
reader = rdbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "dctx":
|
||||
reader = rdctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drawdb":
|
||||
reader = rdrawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "graphql":
|
||||
reader = rgraphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "json":
|
||||
reader = rjson.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "yaml":
|
||||
reader = ryaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "gorm":
|
||||
reader = rgorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "bun":
|
||||
reader = rbun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "drizzle":
|
||||
reader = rdrizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "prisma":
|
||||
reader = rprisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "typeorm":
|
||||
reader = rtypeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
case "pgsql":
|
||||
reader = rpgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
default:
|
||||
se.showErrorDialog("Error", "Unsupported format: "+format)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the database to import
|
||||
importDb, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
se.showErrorDialog("Import Error", fmt.Sprintf("Failed to read database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
se.showImportConfirmation(importDb, skipDomains, skipRelations, skipEnums, skipViews, skipSequences, skipTables)
|
||||
}
|
||||
|
||||
// showImportConfirmation shows a confirmation dialog before merging
|
||||
func (se *SchemaEditor) showImportConfirmation(importDb *models.Database, skipDomains, skipRelations, skipEnums, skipViews, skipSequences bool, skipTables string) {
|
||||
confirmText := fmt.Sprintf("Import & Merge Database?\n\nSource: %s\nTarget: %s\n\nThis will add missing schemas, tables, columns, and other objects from the source to your database.\n\nExisting items will NOT be modified.",
|
||||
importDb.Name, se.db.Name)
|
||||
|
||||
modal := tview.NewModal().
|
||||
SetText(confirmText).
|
||||
AddButtons([]string{"Cancel", "Merge"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
se.pages.RemovePage("import-confirm")
|
||||
if buttonLabel == "Merge" {
|
||||
se.performMerge(importDb, skipDomains, skipRelations, skipEnums, skipViews, skipSequences, skipTables)
|
||||
}
|
||||
})
|
||||
|
||||
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("import-confirm")
|
||||
se.pages.SwitchToPage("import-database")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddAndSwitchToPage("import-confirm", modal, true)
|
||||
}
|
||||
|
||||
// performMerge performs the actual merge operation
|
||||
func (se *SchemaEditor) performMerge(importDb *models.Database, skipDomains, skipRelations, skipEnums, skipViews, skipSequences bool, skipTables string) {
|
||||
// Create merge options
|
||||
opts := &merge.MergeOptions{
|
||||
SkipDomains: skipDomains,
|
||||
SkipRelations: skipRelations,
|
||||
SkipEnums: skipEnums,
|
||||
SkipViews: skipViews,
|
||||
SkipSequences: skipSequences,
|
||||
}
|
||||
|
||||
// Parse skip tables
|
||||
if skipTables != "" {
|
||||
opts.SkipTableNames = parseSkipTablesUI(skipTables)
|
||||
}
|
||||
|
||||
// Perform the merge
|
||||
result := merge.MergeDatabases(se.db, importDb, opts)
|
||||
|
||||
// Update the database timestamp
|
||||
se.db.UpdateDate()
|
||||
|
||||
// Show success dialog with summary
|
||||
summary := merge.GetMergeSummary(result)
|
||||
se.showSuccessDialog("Import Complete", summary, func() {
|
||||
se.pages.RemovePage("import-database")
|
||||
se.pages.RemovePage("main")
|
||||
se.pages.AddPage("main", se.createMainMenu(), true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// getImportHelpText returns the help text for the import screen
|
||||
func getImportHelpText() string {
|
||||
return `Import & Merge: Adds missing schemas, tables, columns, and other objects to your existing database.
|
||||
|
||||
File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
|
||||
Database formats: pgsql (requires connection string)
|
||||
|
||||
Skip options: Check to exclude specific object types from the merge.`
|
||||
}
|
||||
|
||||
func parseSkipTablesUI(skipTablesStr string) map[string]bool {
|
||||
skipTables := make(map[string]bool)
|
||||
if skipTablesStr == "" {
|
||||
return skipTables
|
||||
}
|
||||
|
||||
// Split by comma and trim whitespace
|
||||
parts := strings.Split(skipTablesStr, ",")
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
// Store in lowercase for case-insensitive matching
|
||||
skipTables[strings.ToLower(trimmed)] = true
|
||||
}
|
||||
}
|
||||
|
||||
return skipTables
|
||||
}
|
||||
65
pkg/ui/main_menu.go
Normal file
65
pkg/ui/main_menu.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// createMainMenu creates the main menu screen
|
||||
func (se *SchemaEditor) createMainMenu() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title with database name
|
||||
dbName := se.db.Name
|
||||
if dbName == "" {
|
||||
dbName = "Untitled"
|
||||
}
|
||||
updateAtStr := ""
|
||||
if se.db.UpdatedAt != "" {
|
||||
updateAtStr = fmt.Sprintf("Updated @ %s", se.db.UpdatedAt)
|
||||
}
|
||||
titleText := fmt.Sprintf("[::b]RelSpec Schema Editor\n[::d]Database: %s %s\n[::d]Press arrow keys to navigate, Enter to select", dbName, updateAtStr)
|
||||
title := tview.NewTextView().
|
||||
SetText(titleText).
|
||||
SetDynamicColors(true)
|
||||
|
||||
// Menu options
|
||||
menu := tview.NewList().
|
||||
AddItem("Edit Database", "Edit database name, description, and properties", 'e', func() {
|
||||
se.showEditDatabaseForm()
|
||||
}).
|
||||
AddItem("Manage Schemas", "View, create, edit, and delete schemas", 's', func() {
|
||||
se.showSchemaList()
|
||||
}).
|
||||
AddItem("Manage Tables", "View and manage tables in schemas", 't', func() {
|
||||
se.showTableList()
|
||||
}).
|
||||
AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() {
|
||||
se.showDomainList()
|
||||
}).
|
||||
AddItem("Import & Merge", "Import and merge schema from another database", 'i', func() {
|
||||
se.showImportScreen()
|
||||
}).
|
||||
AddItem("Save Database", "Save database to file or database", 'w', func() {
|
||||
se.showSaveScreen()
|
||||
}).
|
||||
AddItem("Exit Editor", "Exit the editor", 'q', func() {
|
||||
se.app.Stop()
|
||||
})
|
||||
|
||||
menu.SetBorder(true).SetTitle(" Menu ").SetTitleAlign(tview.AlignLeft)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitEditorConfirm()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 5, 0, false).
|
||||
AddItem(menu, 0, 1, true)
|
||||
|
||||
return flex
|
||||
}
|
||||
55
pkg/ui/schema_dataops.go
Normal file
55
pkg/ui/schema_dataops.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Schema data operations - business logic for schema management
|
||||
|
||||
// CreateSchema creates a new schema and adds it to the database
|
||||
func (se *SchemaEditor) CreateSchema(name, description string) *models.Schema {
|
||||
newSchema := &models.Schema{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Tables: make([]*models.Table, 0),
|
||||
Sequences: make([]*models.Sequence, 0),
|
||||
Enums: make([]*models.Enum, 0),
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
se.db.Schemas = append(se.db.Schemas, newSchema)
|
||||
return newSchema
|
||||
}
|
||||
|
||||
// UpdateSchema updates an existing schema's properties
|
||||
func (se *SchemaEditor) UpdateSchema(schemaIndex int, name, owner, description string) {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
schema.Name = name
|
||||
schema.Owner = owner
|
||||
schema.Description = description
|
||||
schema.UpdateDate()
|
||||
}
|
||||
|
||||
// DeleteSchema removes a schema from the database
|
||||
func (se *SchemaEditor) DeleteSchema(schemaIndex int) bool {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return false
|
||||
}
|
||||
se.db.UpdateDate()
|
||||
se.db.Schemas = append(se.db.Schemas[:schemaIndex], se.db.Schemas[schemaIndex+1:]...)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSchema returns a schema by index
|
||||
func (se *SchemaEditor) GetSchema(schemaIndex int) *models.Schema {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
return se.db.Schemas[schemaIndex]
|
||||
}
|
||||
|
||||
// GetAllSchemas returns all schemas
|
||||
func (se *SchemaEditor) GetAllSchemas() []*models.Schema {
|
||||
return se.db.Schemas
|
||||
}
|
||||
362
pkg/ui/schema_screens.go
Normal file
362
pkg/ui/schema_screens.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showSchemaList displays the schema management screen
|
||||
func (se *SchemaEditor) showSchemaList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Manage Schemas").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create schemas table
|
||||
schemaTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Sequence", "Total Tables", "Total Sequences", "Total Views", "GUID", "Description"}
|
||||
headerWidths := []int{20, 15, 20, 20, 15, 36} // Last column takes remaining space
|
||||
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)
|
||||
schemaTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Add existing schemas
|
||||
for row, schema := range se.db.Schemas {
|
||||
schema := schema // capture for closure
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", schema.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Sequence - pad to 15 chars
|
||||
seqStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", schema.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 1, seqCell)
|
||||
|
||||
// Total Tables - pad to 20 chars
|
||||
tablesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(schema.Tables)))
|
||||
tablesCell := tview.NewTableCell(tablesStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 2, tablesCell)
|
||||
|
||||
// Total Sequences - pad to 20 chars
|
||||
sequencesStr := fmt.Sprintf("%-20s", fmt.Sprintf("%d", len(schema.Sequences)))
|
||||
sequencesCell := tview.NewTableCell(sequencesStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 3, sequencesCell)
|
||||
|
||||
// Total Views - pad to 15 chars
|
||||
viewsStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", len(schema.Views)))
|
||||
viewsCell := tview.NewTableCell(viewsStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 4, viewsCell)
|
||||
|
||||
// GUID - pad to 36 chars
|
||||
guidStr := fmt.Sprintf("%-36s", schema.GUID)
|
||||
guidCell := tview.NewTableCell(guidStr).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 5, guidCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(schema.Description).SetSelectable(true)
|
||||
schemaTable.SetCell(row+1, 6, descCell)
|
||||
}
|
||||
|
||||
schemaTable.SetTitle(" Schemas ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewSchema := tview.NewButton("New Schema [n]").SetSelectedFunc(func() {
|
||||
se.showNewSchemaDialog()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewSchema.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(schemaTable)
|
||||
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(btnNewSchema)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(schemaTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewSchema, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
schemaTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewSchema)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := schemaTable.GetSelection()
|
||||
if row > 0 && row <= len(se.db.Schemas) { // Skip header row
|
||||
schemaIndex := row - 1
|
||||
se.showSchemaEditor(schemaIndex, se.db.Schemas[schemaIndex])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewSchemaDialog()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("schemas")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(schemaTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("schemas", flex, true, true)
|
||||
}
|
||||
|
||||
// showSchemaEditor shows the editor for a specific schema
|
||||
func (se *SchemaEditor) showSchemaEditor(index int, schema *models.Schema) {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText(fmt.Sprintf("[::b]Schema: %s", schema.Name)).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Schema info display
|
||||
info := tview.NewTextView().SetDynamicColors(true)
|
||||
info.SetText(fmt.Sprintf("Tables: %d | Description: %s",
|
||||
len(schema.Tables), schema.Description))
|
||||
|
||||
// Table list
|
||||
tableList := tview.NewList().ShowSecondaryText(true)
|
||||
|
||||
for i, table := range schema.Tables {
|
||||
tableIndex := i
|
||||
table := table
|
||||
colCount := len(table.Columns)
|
||||
tableList.AddItem(table.Name, fmt.Sprintf("%d columns", colCount), rune('0'+i), func() {
|
||||
se.showTableEditor(index, tableIndex, table)
|
||||
})
|
||||
}
|
||||
|
||||
tableList.AddItem("[New Table]", "Add a new table to this schema", 'n', func() {
|
||||
se.showNewTableDialog(index)
|
||||
})
|
||||
|
||||
tableList.AddItem("[Edit Schema Info]", "Edit schema properties", 'e', func() {
|
||||
se.showEditSchemaDialog(index)
|
||||
})
|
||||
|
||||
tableList.AddItem("[Delete Schema]", "Delete this schema", 'd', func() {
|
||||
se.showDeleteSchemaConfirm(index)
|
||||
})
|
||||
|
||||
tableList.SetBorder(true).SetTitle(" Tables ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewTable := tview.NewButton("New Table [n]").SetSelectedFunc(func() {
|
||||
se.showNewTableDialog(index)
|
||||
})
|
||||
btnBack := tview.NewButton("Back to Schemas [b]").SetSelectedFunc(func() {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(tableList)
|
||||
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(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(tableList)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
tableList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.pages.SwitchToPage("schemas")
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewTable, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(info, 2, 0, false).
|
||||
AddItem(tableList, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("schema-editor", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewSchemaDialog shows dialog to create a new schema
|
||||
func (se *SchemaEditor) showNewSchemaDialog() {
|
||||
form := tview.NewForm()
|
||||
|
||||
schemaName := ""
|
||||
description := ""
|
||||
|
||||
form.AddInputField("Schema Name", "", 40, nil, func(value string) {
|
||||
schemaName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if schemaName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateSchema(schemaName, description)
|
||||
|
||||
se.pages.RemovePage("new-schema")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-schema")
|
||||
se.pages.RemovePage("schemas")
|
||||
se.showSchemaList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Schema ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-schema", "schemas")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-schema", form, true, true)
|
||||
}
|
||||
|
||||
// showEditSchemaDialog shows dialog to edit schema properties
|
||||
func (se *SchemaEditor) showEditSchemaDialog(schemaIndex int) {
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
form := tview.NewForm()
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := schema.Name
|
||||
newOwner := schema.Owner
|
||||
newDescription := schema.Description
|
||||
newGUID := schema.GUID
|
||||
|
||||
form.AddInputField("Schema Name", schema.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Owner", schema.Owner, 40, nil, func(value string) {
|
||||
newOwner = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", schema.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddInputField("GUID", schema.GUID, 40, nil, func(value string) {
|
||||
newGUID = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateSchema(schemaIndex, newName, newOwner, newDescription)
|
||||
se.db.Schemas[schemaIndex].GUID = newGUID
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("edit-schema")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("edit-schema")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Schema ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-schema", "schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-schema", form, true, true)
|
||||
}
|
||||
88
pkg/ui/table_dataops.go
Normal file
88
pkg/ui/table_dataops.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package ui
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// Table data operations - business logic for table management
|
||||
|
||||
// CreateTable creates a new table and adds it to a schema
|
||||
func (se *SchemaEditor) CreateTable(schemaIndex int, name, description string) *models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
newTable := &models.Table{
|
||||
Name: name,
|
||||
Schema: schema.Name,
|
||||
Description: description,
|
||||
Columns: make(map[string]*models.Column),
|
||||
Constraints: make(map[string]*models.Constraint),
|
||||
Indexes: make(map[string]*models.Index),
|
||||
}
|
||||
schema.UpdateDate()
|
||||
schema.Tables = append(schema.Tables, newTable)
|
||||
return newTable
|
||||
}
|
||||
|
||||
// UpdateTable updates an existing table's properties
|
||||
func (se *SchemaEditor) UpdateTable(schemaIndex, tableIndex int, name, description string) {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return
|
||||
}
|
||||
schema.UpdateDate()
|
||||
table := schema.Tables[tableIndex]
|
||||
table.Name = name
|
||||
table.Description = description
|
||||
table.UpdateDate()
|
||||
}
|
||||
|
||||
// DeleteTable removes a table from a schema
|
||||
func (se *SchemaEditor) DeleteTable(schemaIndex, tableIndex int) 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
|
||||
}
|
||||
schema.UpdateDate()
|
||||
schema.Tables = append(schema.Tables[:tableIndex], schema.Tables[tableIndex+1:]...)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetTable returns a table by schema and table index
|
||||
func (se *SchemaEditor) GetTable(schemaIndex, tableIndex int) *models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return schema.Tables[tableIndex]
|
||||
}
|
||||
|
||||
// GetAllTables returns all tables across all schemas
|
||||
func (se *SchemaEditor) GetAllTables() []*models.Table {
|
||||
var tables []*models.Table
|
||||
for _, schema := range se.db.Schemas {
|
||||
tables = append(tables, schema.Tables...)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
// GetTablesInSchema returns all tables in a specific schema
|
||||
func (se *SchemaEditor) GetTablesInSchema(schemaIndex int) []*models.Table {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil
|
||||
}
|
||||
return se.db.Schemas[schemaIndex].Tables
|
||||
}
|
||||
546
pkg/ui/table_screens.go
Normal file
546
pkg/ui/table_screens.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// showTableList displays all tables across all schemas
|
||||
func (se *SchemaEditor) showTableList() {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]All Tables").
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Create tables table
|
||||
tableTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Schema", "Sequence", "Total Columns", "Total Relations", "Total Indexes", "GUID", "Description", "Comment"}
|
||||
headerWidths := []int{18, 15, 12, 14, 15, 14, 36, 0, 12} // Description gets remainder
|
||||
for i, header := range headers {
|
||||
padding := ""
|
||||
if i < len(headerWidths) && headerWidths[i] > 0 {
|
||||
padding = strings.Repeat(" ", headerWidths[i]-len(header))
|
||||
}
|
||||
cell := tview.NewTableCell(header + padding).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetAlign(tview.AlignLeft)
|
||||
tableTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
var tables []*models.Table
|
||||
var tableLocations []struct{ schemaIdx, tableIdx int }
|
||||
|
||||
for si, schema := range se.db.Schemas {
|
||||
for ti, table := range schema.Tables {
|
||||
tables = append(tables, table)
|
||||
tableLocations = append(tableLocations, struct{ schemaIdx, tableIdx int }{si, ti})
|
||||
}
|
||||
}
|
||||
|
||||
for row, table := range tables {
|
||||
tableIdx := tableLocations[row]
|
||||
schema := se.db.Schemas[tableIdx.schemaIdx]
|
||||
|
||||
// Name - pad to 18 chars
|
||||
nameStr := fmt.Sprintf("%-18s", table.Name)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Schema - pad to 15 chars
|
||||
schemaStr := fmt.Sprintf("%-15s", schema.Name)
|
||||
schemaCell := tview.NewTableCell(schemaStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 1, schemaCell)
|
||||
|
||||
// Sequence - pad to 12 chars
|
||||
seqStr := fmt.Sprintf("%-12s", fmt.Sprintf("%d", table.Sequence))
|
||||
seqCell := tview.NewTableCell(seqStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 2, seqCell)
|
||||
|
||||
// Total Columns - pad to 14 chars
|
||||
colsStr := fmt.Sprintf("%-14s", fmt.Sprintf("%d", len(table.Columns)))
|
||||
colsCell := tview.NewTableCell(colsStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 3, colsCell)
|
||||
|
||||
// Total Relations - pad to 15 chars
|
||||
relsStr := fmt.Sprintf("%-15s", fmt.Sprintf("%d", len(table.Relationships)))
|
||||
relsCell := tview.NewTableCell(relsStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 4, relsCell)
|
||||
|
||||
// Total Indexes - pad to 14 chars
|
||||
idxStr := fmt.Sprintf("%-14s", fmt.Sprintf("%d", len(table.Indexes)))
|
||||
idxCell := tview.NewTableCell(idxStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 5, idxCell)
|
||||
|
||||
// GUID - pad to 36 chars
|
||||
guidStr := fmt.Sprintf("%-36s", table.GUID)
|
||||
guidCell := tview.NewTableCell(guidStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 6, guidCell)
|
||||
|
||||
// Description - no padding, takes remaining space
|
||||
descCell := tview.NewTableCell(table.Description).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 7, descCell)
|
||||
|
||||
// Comment - pad to 12 chars
|
||||
commentStr := fmt.Sprintf("%-12s", table.Comment)
|
||||
commentCell := tview.NewTableCell(commentStr).SetSelectable(true)
|
||||
tableTable.SetCell(row+1, 8, commentCell)
|
||||
}
|
||||
|
||||
tableTable.SetTitle(" All Tables ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewTable := tview.NewButton("New Table [n]").SetSelectedFunc(func() {
|
||||
se.showNewTableDialogFromList()
|
||||
})
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(tableTable)
|
||||
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(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(tableTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewTable, 0, 1, true).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
tableTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := tableTable.GetSelection()
|
||||
if row > 0 && row <= len(tables) { // Skip header row
|
||||
tableIdx := tableLocations[row-1]
|
||||
se.showTableEditor(tableIdx.schemaIdx, tableIdx.tableIdx, tables[row-1])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'n' {
|
||||
se.showNewTableDialogFromList()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage("tables")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(tableTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("tables", flex, true, true)
|
||||
}
|
||||
|
||||
// showTableEditor shows editor for a specific table
|
||||
func (se *SchemaEditor) showTableEditor(schemaIndex, tableIndex int, table *models.Table) {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Title
|
||||
title := tview.NewTextView().
|
||||
SetText(fmt.Sprintf("[::b]Table: %s", table.Name)).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
|
||||
// Table info
|
||||
info := tview.NewTextView().SetDynamicColors(true)
|
||||
info.SetText(fmt.Sprintf("Schema: %s | Columns: %d | Description: %s",
|
||||
table.Schema, len(table.Columns), table.Description))
|
||||
|
||||
// Create columns table
|
||||
colTable := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
|
||||
// Add header row with padding for full width
|
||||
headers := []string{"Name", "Type", "Default", "KeyType", "GUID", "Description"}
|
||||
headerWidths := []int{20, 18, 15, 15, 36} // Last column takes remaining space
|
||||
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)
|
||||
colTable.SetCell(0, i, cell)
|
||||
}
|
||||
|
||||
// Get sorted column names
|
||||
columnNames := getColumnNames(table)
|
||||
for row, colName := range columnNames {
|
||||
column := table.Columns[colName]
|
||||
|
||||
// Name - pad to 20 chars
|
||||
nameStr := fmt.Sprintf("%-20s", colName)
|
||||
nameCell := tview.NewTableCell(nameStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 0, nameCell)
|
||||
|
||||
// Type - pad to 18 chars
|
||||
typeStr := fmt.Sprintf("%-18s", column.Type)
|
||||
typeCell := tview.NewTableCell(typeStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 1, typeCell)
|
||||
|
||||
// Default - pad to 15 chars
|
||||
defaultStr := ""
|
||||
if column.Default != nil {
|
||||
defaultStr = fmt.Sprintf("%v", column.Default)
|
||||
}
|
||||
defaultStr = fmt.Sprintf("%-15s", defaultStr)
|
||||
defaultCell := tview.NewTableCell(defaultStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 2, defaultCell)
|
||||
|
||||
// KeyType - pad to 15 chars
|
||||
keyTypeStr := ""
|
||||
if column.IsPrimaryKey {
|
||||
keyTypeStr = "PRIMARY"
|
||||
} else if column.NotNull {
|
||||
keyTypeStr = "NOT NULL"
|
||||
}
|
||||
keyTypeStr = fmt.Sprintf("%-15s", keyTypeStr)
|
||||
keyTypeCell := tview.NewTableCell(keyTypeStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 3, keyTypeCell)
|
||||
|
||||
// GUID - pad to 36 chars
|
||||
guidStr := fmt.Sprintf("%-36s", column.GUID)
|
||||
guidCell := tview.NewTableCell(guidStr).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 4, guidCell)
|
||||
|
||||
// Description
|
||||
descCell := tview.NewTableCell(column.Description).SetSelectable(true)
|
||||
colTable.SetCell(row+1, 5, descCell)
|
||||
}
|
||||
|
||||
colTable.SetTitle(" Columns ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
// Action buttons flex (define before input capture)
|
||||
btnFlex := tview.NewFlex()
|
||||
btnNewCol := tview.NewButton("Add Column [n]").SetSelectedFunc(func() {
|
||||
se.showNewColumnDialog(schemaIndex, tableIndex)
|
||||
})
|
||||
btnEditTable := tview.NewButton("Edit Table [e]").SetSelectedFunc(func() {
|
||||
se.showEditTableDialog(schemaIndex, tableIndex)
|
||||
})
|
||||
btnEditColumn := tview.NewButton("Edit Column [c]").SetSelectedFunc(func() {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 && row <= len(columnNames) { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
}
|
||||
})
|
||||
btnDelTable := tview.NewButton("Delete Table [d]").SetSelectedFunc(func() {
|
||||
se.showDeleteTableConfirm(schemaIndex, tableIndex)
|
||||
})
|
||||
btnBack := tview.NewButton("Back to Schema [b]").SetSelectedFunc(func() {
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
})
|
||||
|
||||
// Set up button input captures for Tab/Shift+Tab navigation
|
||||
btnNewCol.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(colTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnEditColumn)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnEditColumn.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnNewCol)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnEditTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnEditTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnEditColumn)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnDelTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnDelTable.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(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
se.app.SetFocus(btnDelTable)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(colTable)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
btnFlex.AddItem(btnNewCol, 0, 1, true).
|
||||
AddItem(btnEditColumn, 0, 1, false).
|
||||
AddItem(btnEditTable, 0, 1, false).
|
||||
AddItem(btnDelTable, 0, 1, false).
|
||||
AddItem(btnBack, 0, 1, false)
|
||||
|
||||
colTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
se.pages.RemovePage("table-editor")
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
se.app.SetFocus(btnNewCol)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if event.Rune() == 'c' {
|
||||
row, _ := colTable.GetSelection()
|
||||
if row > 0 && row <= len(columnNames) { // Skip header row
|
||||
colName := columnNames[row-1]
|
||||
column := table.Columns[colName]
|
||||
se.showColumnEditor(schemaIndex, tableIndex, row-1, column)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'b' {
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.pages.SwitchToPage("schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).
|
||||
AddItem(info, 2, 0, false).
|
||||
AddItem(colTable, 0, 1, true).
|
||||
AddItem(btnFlex, 1, 0, false)
|
||||
|
||||
se.pages.AddPage("table-editor", flex, true, true)
|
||||
}
|
||||
|
||||
// showNewTableDialog shows dialog to create a new table
|
||||
func (se *SchemaEditor) showNewTableDialog(schemaIndex int) {
|
||||
form := tview.NewForm()
|
||||
|
||||
tableName := ""
|
||||
description := ""
|
||||
|
||||
form.AddInputField("Table Name", "", 40, nil, func(value string) {
|
||||
tableName = value
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if tableName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateTable(schemaIndex, tableName, description)
|
||||
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("new-table")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
schema := se.db.Schemas[schemaIndex]
|
||||
se.pages.RemovePage("new-table")
|
||||
se.pages.RemovePage("schema-editor")
|
||||
se.showSchemaEditor(schemaIndex, schema)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-table", "schema-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-table", form, true, true)
|
||||
}
|
||||
|
||||
// showNewTableDialogFromList shows dialog to create a new table with schema selection
|
||||
func (se *SchemaEditor) showNewTableDialogFromList() {
|
||||
form := tview.NewForm()
|
||||
|
||||
tableName := ""
|
||||
description := ""
|
||||
selectedSchemaIdx := 0
|
||||
|
||||
// Create schema dropdown options
|
||||
schemaOptions := make([]string, len(se.db.Schemas))
|
||||
for i, schema := range se.db.Schemas {
|
||||
schemaOptions[i] = schema.Name
|
||||
}
|
||||
|
||||
form.AddInputField("Table Name", "", 40, nil, func(value string) {
|
||||
tableName = value
|
||||
})
|
||||
|
||||
form.AddDropDown("Schema", schemaOptions, 0, func(option string, optionIndex int) {
|
||||
selectedSchemaIdx = optionIndex
|
||||
})
|
||||
|
||||
form.AddInputField("Description", "", 40, nil, func(value string) {
|
||||
description = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
if tableName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
se.CreateTable(selectedSchemaIdx, tableName, description)
|
||||
|
||||
se.pages.RemovePage("new-table-from-list")
|
||||
se.pages.RemovePage("tables")
|
||||
se.showTableList()
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
se.pages.RemovePage("new-table-from-list")
|
||||
se.pages.RemovePage("tables")
|
||||
se.showTableList()
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" New Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("new-table-from-list", "tables")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("new-table-from-list", form, true, true)
|
||||
}
|
||||
|
||||
// showEditTableDialog shows dialog to edit table properties
|
||||
func (se *SchemaEditor) showEditTableDialog(schemaIndex, tableIndex int) {
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
form := tview.NewForm()
|
||||
|
||||
// Local variables to collect changes
|
||||
newName := table.Name
|
||||
newDescription := table.Description
|
||||
newGUID := table.GUID
|
||||
|
||||
form.AddInputField("Table Name", table.Name, 40, nil, func(value string) {
|
||||
newName = value
|
||||
})
|
||||
|
||||
form.AddTextArea("Description", table.Description, 40, 5, 0, func(value string) {
|
||||
newDescription = value
|
||||
})
|
||||
|
||||
form.AddInputField("GUID", table.GUID, 40, nil, func(value string) {
|
||||
newGUID = value
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes using dataops
|
||||
se.UpdateTable(schemaIndex, tableIndex, newName, newDescription)
|
||||
se.db.Schemas[schemaIndex].Tables[tableIndex].GUID = newGUID
|
||||
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("edit-table")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply them
|
||||
table := se.db.Schemas[schemaIndex].Tables[tableIndex]
|
||||
se.pages.RemovePage("edit-table")
|
||||
se.pages.RemovePage("table-editor")
|
||||
se.showTableEditor(schemaIndex, tableIndex, table)
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(" Edit Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation("edit-table", "table-editor")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
se.pages.AddPage("edit-table", form, true, true)
|
||||
}
|
||||
299
pkg/ui/ui_rules.md
Normal file
299
pkg/ui/ui_rules.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# UI Rules and Guidelines
|
||||
|
||||
## Layout Requirements
|
||||
|
||||
All layouts / forms must be in seperate files regarding their domain or entity.
|
||||
|
||||
### Screen Layout Structure
|
||||
|
||||
All screens must follow this consistent layout:
|
||||
|
||||
1. **Title at the Top** (1 row, fixed height)
|
||||
- Centered bold text: `[::b]Title Text`
|
||||
- Use `tview.NewTextView()` with `SetTextAlign(tview.AlignCenter)`
|
||||
- Enable dynamic colors: `SetDynamicColors(true)`
|
||||
|
||||
2. **Content in the Middle** (flexible height)
|
||||
- Tables, lists, forms, or info displays
|
||||
- Uses flex weight of 1 for dynamic sizing
|
||||
|
||||
3. **Action Buttons at the Bottom** (1 row, fixed height)
|
||||
- Must be in a horizontal flex container
|
||||
- Action buttons before Back button
|
||||
- Back button is always last
|
||||
|
||||
### Form Layout Structure
|
||||
|
||||
All forms must follow this button order:
|
||||
|
||||
1. **Save Button** (always first)
|
||||
- Label: "Save"
|
||||
- Primary action that commits changes
|
||||
|
||||
2. **Delete Button** (optional, only for edit forms)
|
||||
- Label: "Delete"
|
||||
- Only shown when editing existing items (not for new items)
|
||||
- Must show confirmation dialog before deletion
|
||||
|
||||
3. **Back Button** (always last)
|
||||
- Label: "Back"
|
||||
- Returns to previous screen without saving
|
||||
|
||||
**Button Order Examples:**
|
||||
- **New Item Forms:** Save, Back
|
||||
- **Edit Item Forms:** Save, Delete, Back
|
||||
|
||||
## Tab Navigation
|
||||
|
||||
All screens must implement circular tab navigation:
|
||||
|
||||
1. **Tab Key** - Moves focus to the next focusable element
|
||||
2. **Shift+Tab (BackTab)** - Moves focus to the previous focusable element
|
||||
3. **At the End** - Tab cycles back to the first element
|
||||
4. **At the Start** - Shift+Tab cycles back to the last element
|
||||
|
||||
**Navigation Flow Pattern:**
|
||||
- Each widget must handle both Tab and BackTab
|
||||
- First widget: BackTab → Last widget, Tab → Second widget
|
||||
- Middle widgets: BackTab → Previous widget, Tab → Next widget
|
||||
- Last widget: BackTab → Previous widget, Tab → First widget
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Standard Keys
|
||||
|
||||
- **ESC** - Cancel current operation or go back to previous screen
|
||||
- **Tab** - Move focus forward (circular)
|
||||
- **Shift+Tab** - Move focus backward (circular)
|
||||
- **Enter** - Activate/select current item in tables and lists
|
||||
|
||||
### Letter Key Shortcuts
|
||||
|
||||
- **'n'** - New (create new item)
|
||||
- **'b'** - Back (return to previous screen)
|
||||
- **'e'** - Edit (edit current item)
|
||||
- **'d'** - Delete (delete current item)
|
||||
- **'c'** - Edit Column (in table editor)
|
||||
- **'s'** - Manage Schemas (in main menu)
|
||||
- **'t'** - Manage Tables (in main menu)
|
||||
- **'q'** - Quit/Exit (in main menu)
|
||||
|
||||
## Consistency Requirements
|
||||
|
||||
1. **Layout Structure** - All screens: Title (top) → Content (middle) → Buttons (bottom)
|
||||
2. **Title Format** - Bold (`[::b]`), centered, dynamic colors enabled
|
||||
3. **Tables** - Fixed headers (row 0), borders enabled, selectable rows
|
||||
4. **Buttons** - Include keyboard shortcuts in labels (e.g., "Back [b]")
|
||||
5. **Forms** - Button order: Save, Delete (if edit), Back
|
||||
6. **Destructive Actions** - Always show confirmation dialogs
|
||||
7. **ESC Key** - All screens support ESC to go back
|
||||
8. **Action Buttons** - Positioned before Back button, in logical order
|
||||
9. **Data Refresh** - Always refresh the previous screen when returning from a form or dialog
|
||||
|
||||
## Widget Naming Conventions
|
||||
|
||||
- **Tables:** `schemaTable`, `tableTable`, `colTable`
|
||||
- **Buttons:** Prefix with `btn` (e.g., `btnBack`, `btnDelete`, `btnNewSchema`)
|
||||
- **Flex containers:** `btnFlex` for button containers, `flex` for main layout
|
||||
- **Forms:** `form`
|
||||
- **Lists:** `list`, `tableList`
|
||||
- **Text views:** `title`, `info`
|
||||
- Use camelCase for all variable names
|
||||
|
||||
## Page Naming Conventions
|
||||
|
||||
Use descriptive kebab-case names:
|
||||
|
||||
- **Main screens:** `main`, `schemas`, `tables`, `schema-editor`, `table-editor`, `column-editor`
|
||||
- **Load/Save screens:** `load-database`, `save-database`
|
||||
- **Creation dialogs:** `new-schema`, `new-table`, `new-column`, `new-table-from-list`
|
||||
- **Edit dialogs:** `edit-schema`, `edit-table`
|
||||
- **Confirmations:** `confirm-delete-schema`, `confirm-delete-table`, `confirm-delete-column`
|
||||
- **Exit confirmations:** `exit-confirm`, `exit-editor-confirm`
|
||||
- **Status dialogs:** `error-dialog`, `success-dialog`
|
||||
|
||||
## Dialog and Confirmation Rules
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
1. **Delete Confirmations** - Required for all destructive actions
|
||||
- Show item name in confirmation text
|
||||
- Buttons: "Cancel", "Delete"
|
||||
- ESC key dismisses dialog
|
||||
|
||||
2. **Exit Confirmations** - Required when exiting forms with potential unsaved changes
|
||||
- Text: "Exit without saving changes?"
|
||||
- Buttons: "Cancel", "No, exit without saving"
|
||||
- ESC key confirms exit
|
||||
|
||||
3. **Save Confirmations** - Optional, based on context
|
||||
- Use for critical data changes
|
||||
- Clear description of what will be saved
|
||||
|
||||
### Dialog Behavior
|
||||
|
||||
- All dialogs must capture ESC key for dismissal
|
||||
- Modal dialogs overlay current screen
|
||||
- Confirmation dialogs use `tview.NewModal()`
|
||||
- Remove dialog page after action completes
|
||||
|
||||
## Data Refresh Rules
|
||||
|
||||
When returning from any form or dialog, the previous screen must be refreshed to show updated data. If Tables exists in the screen, their data must be updated:
|
||||
|
||||
1. **After Save** - Remove and recreate the previous screen to display updated data
|
||||
2. **After Delete** - Remove and recreate the previous screen to display remaining data
|
||||
3. **After Cancel/Back** - Remove and recreate the previous screen (data may have changed)
|
||||
4. **Implementation Pattern** - Remove the current page, remove the previous page, then recreate the previous page with fresh data
|
||||
|
||||
**Why This Matters:**
|
||||
- Ensures users see their changes immediately
|
||||
- Prevents stale data from being displayed
|
||||
- Maintains data consistency across the UI
|
||||
- Avoids confusion from seeing outdated information
|
||||
|
||||
**Example Flow:**
|
||||
```
|
||||
User on Schema List → Opens Edit Schema Form → Saves Changes →
|
||||
Returns to Schema List (refreshed with updated schema data)
|
||||
```
|
||||
|
||||
## Big Loading/Saving Operations
|
||||
|
||||
When loading big changes, files or data, always give a load completed or load error dialog.
|
||||
Do the same with saving.
|
||||
This informs the user what happens.
|
||||
When data is dirty, always ask the user to save when trying to exit.
|
||||
|
||||
### Load/Save Screens
|
||||
|
||||
- **Load Screen** (`load-database`) - Shown when no source is specified via command line
|
||||
- Format dropdown (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)
|
||||
- File path input (for file-based formats)
|
||||
- Connection string input (for database formats like pgsql)
|
||||
- Load button [l] - Loads the database
|
||||
- Create New button [n] - Creates a new empty database
|
||||
- Exit button [q] - Exits the application
|
||||
- ESC key exits the application
|
||||
|
||||
- **Save Screen** (`save-database`) - Accessible from main menu with 'w' key
|
||||
- Format dropdown (same as load screen)
|
||||
- File path input
|
||||
- Help text explaining format requirements
|
||||
- Save button [s] - Saves the database
|
||||
- Back button [b] - Returns to main menu
|
||||
- ESC key returns to main menu
|
||||
- Pre-populated with existing save configuration if available
|
||||
|
||||
### Status Dialogs
|
||||
|
||||
- **Error Dialog** (`error-dialog`) - Shows error messages with OK button and ESC to dismiss
|
||||
- **Success Dialog** (`success-dialog`) - Shows success messages with OK button, ESC to dismiss, and optional callback on close
|
||||
|
||||
## Screen Organization
|
||||
|
||||
Organize UI code into these files:
|
||||
|
||||
### UI Files (Screens and Dialogs)
|
||||
|
||||
- **editor.go** - Core `SchemaEditor` struct, constructor, `Run()` method, helper functions
|
||||
- **main_menu.go** - Main menu screen
|
||||
- **load_save_screens.go** - Database load and save screens
|
||||
- **database_screens.go** - Database edit form
|
||||
- **schema_screens.go** - Schema list, schema editor, new/edit schema dialogs
|
||||
- **table_screens.go** - Tables list, table editor, new/edit table dialogs
|
||||
- **column_screens.go** - Column editor, new column dialog
|
||||
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
|
||||
- **dialogs.go** - Confirmation dialogs (exit, delete)
|
||||
|
||||
### Data Operations Files (Business Logic)
|
||||
|
||||
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
|
||||
- **table_dataops.go** - Table CRUD operations
|
||||
- **column_dataops.go** - Column CRUD operations
|
||||
|
||||
## Code Separation Rules
|
||||
|
||||
### UI vs Business Logic
|
||||
|
||||
1. **UI Files** - Handle only presentation and user interaction
|
||||
- Display data in tables, lists, and forms
|
||||
- Capture user input
|
||||
- Navigate between screens
|
||||
- Show/hide dialogs
|
||||
- Call dataops methods for actual data changes
|
||||
|
||||
2. **Dataops Files** - Handle only business logic and data manipulation
|
||||
- Create, read, update, delete operations
|
||||
- Data validation
|
||||
- Data structure manipulation
|
||||
- Return created/updated objects or success/failure status
|
||||
- No UI code or tview references
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
#### Creating New Items
|
||||
|
||||
**Bad (Direct Data Manipulation in UI):**
|
||||
```go
|
||||
form.AddButton("Save", func() {
|
||||
schema := &models.Schema{Name: name, Description: desc, ...}
|
||||
se.db.Schemas = append(se.db.Schemas, schema)
|
||||
})
|
||||
```
|
||||
|
||||
**Good (Using Dataops Methods):**
|
||||
```go
|
||||
form.AddButton("Save", func() {
|
||||
se.CreateSchema(name, description)
|
||||
})
|
||||
```
|
||||
|
||||
#### Editing Existing Items
|
||||
|
||||
**Bad (Modifying Data in onChange Callbacks):**
|
||||
```go
|
||||
form.AddInputField("Name", column.Name, 40, nil, func(value string) {
|
||||
column.Name = value // Changes immediately as user types!
|
||||
})
|
||||
form.AddButton("Save", func() {
|
||||
// Data already changed, just refresh screen
|
||||
})
|
||||
```
|
||||
|
||||
**Good (Local Variables + Dataops on Save):**
|
||||
```go
|
||||
// Store original values
|
||||
originalName := column.Name
|
||||
newName := column.Name
|
||||
|
||||
form.AddInputField("Name", column.Name, 40, nil, func(value string) {
|
||||
newName = value // Store in local variable
|
||||
})
|
||||
|
||||
form.AddButton("Save", func() {
|
||||
// Apply changes only when Save is clicked
|
||||
se.UpdateColumn(schemaIndex, tableIndex, originalName, newName, ...)
|
||||
// Then refresh screen
|
||||
})
|
||||
|
||||
form.AddButton("Back", func() {
|
||||
// Discard changes - don't apply local variables
|
||||
// Just refresh screen
|
||||
})
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
**Edit Forms Must Use Local Variables:**
|
||||
1. **Deferred Changes** - Changes only apply when Save is clicked
|
||||
2. **Cancellable** - Back button discards changes without saving
|
||||
3. **Handles Renames** - Original name preserved to update map keys correctly
|
||||
4. **User Expectations** - Save means "commit changes", Back means "cancel"
|
||||
|
||||
This separation ensures:
|
||||
- Cleaner, more maintainable code
|
||||
- Reusable business logic
|
||||
- Easier testing
|
||||
- Clear separation of concerns
|
||||
- Proper change management (save vs cancel)
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// TemplateData represents the data passed to the template for code generation
|
||||
@@ -111,13 +112,17 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
tableName = schema + "." + table.Name
|
||||
}
|
||||
|
||||
// Generate model name: singularize and convert to PascalCase
|
||||
// Generate model name: Model + Schema + Table (all PascalCase)
|
||||
singularTable := Singularize(table.Name)
|
||||
modelName := SnakeCaseToPascalCase(singularTable)
|
||||
tablePart := SnakeCaseToPascalCase(singularTable)
|
||||
|
||||
// Add "Model" prefix if not already present
|
||||
if !hasModelPrefix(modelName) {
|
||||
modelName = "Model" + modelName
|
||||
// Include schema name in model name
|
||||
var modelName string
|
||||
if schema != "" {
|
||||
schemaPart := SnakeCaseToPascalCase(schema)
|
||||
modelName = "Model" + schemaPart + tablePart
|
||||
} else {
|
||||
modelName = "Model" + tablePart
|
||||
}
|
||||
|
||||
model := &ModelData{
|
||||
@@ -133,8 +138,10 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
// Find primary key
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
model.PrimaryKeyField = SnakeCaseToPascalCase(col.Name)
|
||||
model.IDColumnName = col.Name
|
||||
// Sanitize column name to remove backticks
|
||||
safeName := writers.SanitizeStructTagValue(col.Name)
|
||||
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
|
||||
model.IDColumnName = safeName
|
||||
// Check if PK type is a SQL type (contains resolvespec_common or sql_types)
|
||||
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
model.PrimaryKeyIsSQL = strings.Contains(goType, "resolvespec_common") || strings.Contains(goType, "sql_types")
|
||||
@@ -146,6 +153,8 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
columns := sortColumns(table.Columns)
|
||||
for _, col := range columns {
|
||||
field := columnToField(col, table, typeMapper)
|
||||
// Check for name collision with generated methods and rename if needed
|
||||
field.Name = resolveFieldNameCollision(field.Name)
|
||||
model.Fields = append(model.Fields, field)
|
||||
}
|
||||
|
||||
@@ -154,10 +163,13 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
|
||||
// columnToField converts a models.Column to FieldData
|
||||
func columnToField(col *models.Column, table *models.Table, typeMapper *TypeMapper) *FieldData {
|
||||
fieldName := SnakeCaseToPascalCase(col.Name)
|
||||
// Sanitize column name first to remove backticks before generating field name
|
||||
safeName := writers.SanitizeStructTagValue(col.Name)
|
||||
fieldName := SnakeCaseToPascalCase(safeName)
|
||||
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
bunTag := typeMapper.BuildBunTag(col, table)
|
||||
jsonTag := col.Name // Use column name for JSON tag
|
||||
// Use same sanitized name for JSON tag
|
||||
jsonTag := safeName
|
||||
|
||||
return &FieldData{
|
||||
Name: fieldName,
|
||||
@@ -184,9 +196,28 @@ func formatComment(description, comment string) string {
|
||||
return comment
|
||||
}
|
||||
|
||||
// hasModelPrefix checks if a name already has "Model" prefix
|
||||
func hasModelPrefix(name string) bool {
|
||||
return len(name) >= 5 && name[:5] == "Model"
|
||||
// resolveFieldNameCollision checks if a field name conflicts with generated method names
|
||||
// and adds an underscore suffix if there's a collision
|
||||
func resolveFieldNameCollision(fieldName string) string {
|
||||
// List of method names that are generated by the template
|
||||
reservedNames := map[string]bool{
|
||||
"TableName": true,
|
||||
"TableNameOnly": true,
|
||||
"SchemaName": true,
|
||||
"GetID": true,
|
||||
"GetIDStr": true,
|
||||
"SetID": true,
|
||||
"UpdateID": true,
|
||||
"GetIDName": true,
|
||||
"GetPrefix": true,
|
||||
}
|
||||
|
||||
// Check if field name conflicts with a reserved method name
|
||||
if reservedNames[fieldName] {
|
||||
return fieldName + "_"
|
||||
}
|
||||
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// sortColumns sorts columns by sequence, then by name
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// TypeMapper handles type conversions between SQL and Go types for Bun
|
||||
@@ -164,11 +165,14 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
||||
var parts []string
|
||||
|
||||
// Column name comes first (no prefix)
|
||||
parts = append(parts, column.Name)
|
||||
// Sanitize to remove backticks which would break struct tag syntax
|
||||
safeName := writers.SanitizeStructTagValue(column.Name)
|
||||
parts = append(parts, safeName)
|
||||
|
||||
// Add type if specified
|
||||
if column.Type != "" {
|
||||
typeStr := column.Type
|
||||
// Sanitize type to remove backticks
|
||||
typeStr := writers.SanitizeStructTagValue(column.Type)
|
||||
if column.Length > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
|
||||
} else if column.Precision > 0 {
|
||||
@@ -188,12 +192,17 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
||||
|
||||
// Default value
|
||||
if column.Default != nil {
|
||||
parts = append(parts, fmt.Sprintf("default:%v", column.Default))
|
||||
// Sanitize default value to remove backticks
|
||||
safeDefault := writers.SanitizeStructTagValue(fmt.Sprintf("%v", column.Default))
|
||||
parts = append(parts, fmt.Sprintf("default:%s", safeDefault))
|
||||
}
|
||||
|
||||
// Nullable (Bun uses nullzero for nullable fields)
|
||||
// and notnull tag for explicitly non-nullable fields
|
||||
if !column.NotNull && !column.IsPrimaryKey {
|
||||
parts = append(parts, "nullzero")
|
||||
} else if column.NotNull && !column.IsPrimaryKey {
|
||||
parts = append(parts, "notnull")
|
||||
}
|
||||
|
||||
// Check for indexes (unique indexes should be added to tag)
|
||||
@@ -260,7 +269,7 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
|
||||
|
||||
// GetSQLTypesImport returns the import path for sql_types (ResolveSpec common)
|
||||
func (tm *TypeMapper) GetSQLTypesImport() string {
|
||||
return "github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
}
|
||||
|
||||
// GetBunImport returns the import path for Bun
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -124,7 +125,16 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
}
|
||||
|
||||
// Write output
|
||||
return w.writeOutput(formatted)
|
||||
if err := w.writeOutput(formatted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run go fmt on the output file
|
||||
if w.options.OutputPath != "" {
|
||||
w.runGoFmt(w.options.OutputPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMultiFile writes each table to a separate file
|
||||
@@ -207,13 +217,19 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
}
|
||||
|
||||
// Generate filename: sql_{schema}_{table}.go
|
||||
filename := fmt.Sprintf("sql_%s_%s.go", schema.Name, table.Name)
|
||||
// Sanitize schema and table names to remove quotes, comments, and invalid characters
|
||||
safeSchemaName := writers.SanitizeFilename(schema.Name)
|
||||
safeTableName := writers.SanitizeFilename(table.Name)
|
||||
filename := fmt.Sprintf("sql_%s_%s.go", safeSchemaName, safeTableName)
|
||||
filepath := filepath.Join(w.options.OutputPath, filename)
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(filepath, []byte(formatted), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Run go fmt on the generated file
|
||||
w.runGoFmt(filepath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +238,9 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
|
||||
// addRelationshipFields adds relationship fields to the model based on foreign keys
|
||||
func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table, schema *models.Schema, db *models.Database) {
|
||||
// Track used field names to detect duplicates
|
||||
usedFieldNames := make(map[string]int)
|
||||
|
||||
// For each foreign key in this table, add a belongs-to/has-one relationship
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
@@ -235,8 +254,9 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
}
|
||||
|
||||
// Create relationship field (has-one in Bun, similar to belongs-to in GORM)
|
||||
refModelName := w.getModelName(constraint.ReferencedTable)
|
||||
fieldName := w.generateRelationshipFieldName(constraint.ReferencedTable)
|
||||
refModelName := w.getModelName(constraint.ReferencedSchema, constraint.ReferencedTable)
|
||||
fieldName := w.generateHasOneFieldName(constraint)
|
||||
fieldName = w.ensureUniqueFieldName(fieldName, usedFieldNames)
|
||||
relationTag := w.typeMapper.BuildRelationshipTag(constraint, "has-one")
|
||||
|
||||
modelData.AddRelationshipField(&FieldData{
|
||||
@@ -263,8 +283,9 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
// Check if this constraint references our table
|
||||
if constraint.ReferencedTable == table.Name && constraint.ReferencedSchema == schema.Name {
|
||||
// Add has-many relationship
|
||||
otherModelName := w.getModelName(otherTable.Name)
|
||||
fieldName := w.generateRelationshipFieldName(otherTable.Name) + "s" // Pluralize
|
||||
otherModelName := w.getModelName(otherSchema.Name, otherTable.Name)
|
||||
fieldName := w.generateHasManyFieldName(constraint, otherSchema.Name, otherTable.Name)
|
||||
fieldName = w.ensureUniqueFieldName(fieldName, usedFieldNames)
|
||||
relationTag := w.typeMapper.BuildRelationshipTag(constraint, "has-many")
|
||||
|
||||
modelData.AddRelationshipField(&FieldData{
|
||||
@@ -295,22 +316,77 @@ func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *m
|
||||
return nil
|
||||
}
|
||||
|
||||
// getModelName generates the model name from a table name
|
||||
func (w *Writer) getModelName(tableName string) string {
|
||||
// getModelName generates the model name from schema and table name
|
||||
func (w *Writer) getModelName(schemaName, tableName string) string {
|
||||
singular := Singularize(tableName)
|
||||
modelName := SnakeCaseToPascalCase(singular)
|
||||
tablePart := SnakeCaseToPascalCase(singular)
|
||||
|
||||
if !hasModelPrefix(modelName) {
|
||||
modelName = "Model" + modelName
|
||||
// Include schema name in model name
|
||||
var modelName string
|
||||
if schemaName != "" {
|
||||
schemaPart := SnakeCaseToPascalCase(schemaName)
|
||||
modelName = "Model" + schemaPart + tablePart
|
||||
} else {
|
||||
modelName = "Model" + tablePart
|
||||
}
|
||||
|
||||
return modelName
|
||||
}
|
||||
|
||||
// generateRelationshipFieldName generates a field name for a relationship
|
||||
func (w *Writer) generateRelationshipFieldName(tableName string) string {
|
||||
// Use just the prefix (3 letters) for relationship fields
|
||||
return GeneratePrefix(tableName)
|
||||
// generateHasOneFieldName generates a field name for has-one relationships
|
||||
// Uses the foreign key column name for uniqueness
|
||||
func (w *Writer) generateHasOneFieldName(constraint *models.Constraint) string {
|
||||
// Use the foreign key column name to ensure uniqueness
|
||||
// If there are multiple columns, use the first one
|
||||
if len(constraint.Columns) > 0 {
|
||||
columnName := constraint.Columns[0]
|
||||
// Convert to PascalCase for proper Go field naming
|
||||
// e.g., "rid_filepointer_request" -> "RelRIDFilepointerRequest"
|
||||
return "Rel" + SnakeCaseToPascalCase(columnName)
|
||||
}
|
||||
|
||||
// Fallback to table-based prefix if no columns defined
|
||||
return "Rel" + GeneratePrefix(constraint.ReferencedTable)
|
||||
}
|
||||
|
||||
// generateHasManyFieldName generates a field name for has-many relationships
|
||||
// Uses the foreign key column name + source table name to avoid duplicates
|
||||
func (w *Writer) generateHasManyFieldName(constraint *models.Constraint, sourceSchemaName, sourceTableName string) string {
|
||||
// For has-many, we need to include the source table name to avoid duplicates
|
||||
// e.g., multiple tables referencing the same column on this table
|
||||
if len(constraint.Columns) > 0 {
|
||||
columnName := constraint.Columns[0]
|
||||
// Get the model name for the source table (pluralized)
|
||||
sourceModelName := w.getModelName(sourceSchemaName, sourceTableName)
|
||||
// Remove "Model" prefix if present
|
||||
sourceModelName = strings.TrimPrefix(sourceModelName, "Model")
|
||||
|
||||
// Convert column to PascalCase and combine with source table
|
||||
// e.g., "rid_api_provider" + "Login" -> "RelRIDAPIProviderLogins"
|
||||
columnPart := SnakeCaseToPascalCase(columnName)
|
||||
return "Rel" + columnPart + Pluralize(sourceModelName)
|
||||
}
|
||||
|
||||
// Fallback to table-based naming
|
||||
sourceModelName := w.getModelName(sourceSchemaName, sourceTableName)
|
||||
sourceModelName = strings.TrimPrefix(sourceModelName, "Model")
|
||||
return "Rel" + Pluralize(sourceModelName)
|
||||
}
|
||||
|
||||
// ensureUniqueFieldName ensures a field name is unique by adding numeric suffixes if needed
|
||||
func (w *Writer) ensureUniqueFieldName(fieldName string, usedNames map[string]int) string {
|
||||
originalName := fieldName
|
||||
count := usedNames[originalName]
|
||||
|
||||
if count > 0 {
|
||||
// Name is already used, add numeric suffix
|
||||
fieldName = fmt.Sprintf("%s%d", originalName, count+1)
|
||||
}
|
||||
|
||||
// Increment the counter for this base name
|
||||
usedNames[originalName]++
|
||||
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// getPackageName returns the package name from options or defaults to "models"
|
||||
@@ -341,6 +417,15 @@ func (w *Writer) writeOutput(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runGoFmt runs go fmt on the specified file
|
||||
func (w *Writer) runGoFmt(filepath string) {
|
||||
cmd := exec.Command("gofmt", "-w", filepath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Don't fail the whole operation if gofmt fails, just warn
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to run gofmt on %s: %v\n", filepath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUseMultiFile determines whether to use multi-file mode based on metadata or output path
|
||||
func (w *Writer) shouldUseMultiFile() bool {
|
||||
// Check if multi_file is explicitly set in metadata
|
||||
@@ -386,6 +471,7 @@ func (w *Writer) createDatabaseRef(db *models.Database) *models.Database {
|
||||
DatabaseVersion: db.DatabaseVersion,
|
||||
SourceFormat: db.SourceFormat,
|
||||
Schemas: nil, // Don't include schemas to avoid circular reference
|
||||
GUID: db.GUID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,5 +488,6 @@ func (w *Writer) createSchemaRef(schema *models.Schema, db *models.Database) *mo
|
||||
Sequence: schema.Sequence,
|
||||
RefDatabase: w.createDatabaseRef(db), // Include database ref
|
||||
Tables: nil, // Don't include tables to avoid circular reference
|
||||
GUID: schema.GUID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
// Verify key elements are present
|
||||
expectations := []string{
|
||||
"package models",
|
||||
"type ModelUser struct",
|
||||
"type ModelPublicUser struct",
|
||||
"bun.BaseModel",
|
||||
"table:public.users",
|
||||
"alias:users",
|
||||
@@ -78,9 +78,9 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
"resolvespec_common.SqlTime",
|
||||
"bun:\"id",
|
||||
"bun:\"email",
|
||||
"func (m ModelUser) TableName() string",
|
||||
"func (m ModelPublicUser) TableName() string",
|
||||
"return \"public.users\"",
|
||||
"func (m ModelUser) GetID() int64",
|
||||
"func (m ModelPublicUser) GetID() int64",
|
||||
}
|
||||
|
||||
for _, expected := range expectations {
|
||||
@@ -175,12 +175,378 @@ func TestWriter_WriteDatabase_MultiFile(t *testing.T) {
|
||||
postsStr := string(postsContent)
|
||||
|
||||
// Verify relationship is present with Bun format
|
||||
if !strings.Contains(postsStr, "USE") {
|
||||
t.Errorf("Missing relationship field USE")
|
||||
// Should now be RelUserID (has-one) instead of USE
|
||||
if !strings.Contains(postsStr, "RelUserID") {
|
||||
t.Errorf("Missing relationship field RelUserID (new naming convention)")
|
||||
}
|
||||
if !strings.Contains(postsStr, "rel:has-one") {
|
||||
t.Errorf("Missing Bun relationship tag: %s", postsStr)
|
||||
}
|
||||
|
||||
// Check users file contains has-many relationship
|
||||
usersContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_public_users.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read users file: %v", err)
|
||||
}
|
||||
|
||||
usersStr := string(usersContent)
|
||||
|
||||
// Should have RelUserIDPublicPosts (has-many) field - includes schema prefix
|
||||
if !strings.Contains(usersStr, "RelUserIDPublicPosts") {
|
||||
t.Errorf("Missing has-many relationship field RelUserIDPublicPosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_MultipleReferencesToSameTable(t *testing.T) {
|
||||
// Test scenario: api_event table with multiple foreign keys to filepointer table
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("org")
|
||||
|
||||
// Filepointer table
|
||||
filepointer := models.InitTable("filepointer", "org")
|
||||
filepointer.Columns["id_filepointer"] = &models.Column{
|
||||
Name: "id_filepointer",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
schema.Tables = append(schema.Tables, filepointer)
|
||||
|
||||
// API event table with two foreign keys to filepointer
|
||||
apiEvent := models.InitTable("api_event", "org")
|
||||
apiEvent.Columns["id_api_event"] = &models.Column{
|
||||
Name: "id_api_event",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiEvent.Columns["rid_filepointer_request"] = &models.Column{
|
||||
Name: "rid_filepointer_request",
|
||||
Type: "bigint",
|
||||
NotNull: false,
|
||||
}
|
||||
apiEvent.Columns["rid_filepointer_response"] = &models.Column{
|
||||
Name: "rid_filepointer_response",
|
||||
Type: "bigint",
|
||||
NotNull: false,
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
apiEvent.Constraints["fk_request"] = &models.Constraint{
|
||||
Name: "fk_request",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_filepointer_request"},
|
||||
ReferencedTable: "filepointer",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_filepointer"},
|
||||
}
|
||||
apiEvent.Constraints["fk_response"] = &models.Constraint{
|
||||
Name: "fk_response",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_filepointer_response"},
|
||||
ReferencedTable: "filepointer",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_filepointer"},
|
||||
}
|
||||
|
||||
schema.Tables = append(schema.Tables, apiEvent)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: tmpDir,
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true,
|
||||
},
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the api_event file
|
||||
apiEventContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_api_event.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read api_event file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(apiEventContent)
|
||||
|
||||
// Verify both relationships have unique names based on column names
|
||||
expectations := []struct {
|
||||
fieldName string
|
||||
tag string
|
||||
}{
|
||||
{"RelRIDFilepointerRequest", "join:rid_filepointer_request=id_filepointer"},
|
||||
{"RelRIDFilepointerResponse", "join:rid_filepointer_response=id_filepointer"},
|
||||
}
|
||||
|
||||
for _, exp := range expectations {
|
||||
if !strings.Contains(contentStr, exp.fieldName) {
|
||||
t.Errorf("Missing relationship field: %s\nGenerated:\n%s", exp.fieldName, contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, exp.tag) {
|
||||
t.Errorf("Missing relationship tag: %s\nGenerated:\n%s", exp.tag, contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify NO duplicate field names (old behavior would create duplicate "FIL" fields)
|
||||
if strings.Contains(contentStr, "FIL *ModelFilepointer") {
|
||||
t.Errorf("Found old prefix-based naming (FIL), should use column-based naming")
|
||||
}
|
||||
|
||||
// Also verify has-many relationships on filepointer table
|
||||
filepointerContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_filepointer.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read filepointer file: %v", err)
|
||||
}
|
||||
|
||||
filepointerStr := string(filepointerContent)
|
||||
|
||||
// Should have two different has-many relationships with unique names
|
||||
hasManyExpectations := []string{
|
||||
"RelRIDFilepointerRequestOrgAPIEvents", // Has many via rid_filepointer_request
|
||||
"RelRIDFilepointerResponseOrgAPIEvents", // Has many via rid_filepointer_response
|
||||
}
|
||||
|
||||
for _, exp := range hasManyExpectations {
|
||||
if !strings.Contains(filepointerStr, exp) {
|
||||
t.Errorf("Missing has-many relationship field: %s\nGenerated:\n%s", exp, filepointerStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_MultipleHasManyRelationships(t *testing.T) {
|
||||
// Test scenario: api_provider table referenced by multiple tables via rid_api_provider
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("org")
|
||||
|
||||
// Owner table
|
||||
owner := models.InitTable("owner", "org")
|
||||
owner.Columns["id_owner"] = &models.Column{
|
||||
Name: "id_owner",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
schema.Tables = append(schema.Tables, owner)
|
||||
|
||||
// API Provider table
|
||||
apiProvider := models.InitTable("api_provider", "org")
|
||||
apiProvider.Columns["id_api_provider"] = &models.Column{
|
||||
Name: "id_api_provider",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiProvider.Columns["rid_owner"] = &models.Column{
|
||||
Name: "rid_owner",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
apiProvider.Constraints["fk_owner"] = &models.Constraint{
|
||||
Name: "fk_owner",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_owner"},
|
||||
ReferencedTable: "owner",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_owner"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, apiProvider)
|
||||
|
||||
// Login table
|
||||
login := models.InitTable("login", "org")
|
||||
login.Columns["id_login"] = &models.Column{
|
||||
Name: "id_login",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
login.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
login.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, login)
|
||||
|
||||
// Filepointer table
|
||||
filepointer := models.InitTable("filepointer", "org")
|
||||
filepointer.Columns["id_filepointer"] = &models.Column{
|
||||
Name: "id_filepointer",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
filepointer.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
filepointer.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, filepointer)
|
||||
|
||||
// API Event table
|
||||
apiEvent := models.InitTable("api_event", "org")
|
||||
apiEvent.Columns["id_api_event"] = &models.Column{
|
||||
Name: "id_api_event",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiEvent.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
apiEvent.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, apiEvent)
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: tmpDir,
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true,
|
||||
},
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the api_provider file
|
||||
apiProviderContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_api_provider.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read api_provider file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(apiProviderContent)
|
||||
|
||||
// Verify all has-many relationships have unique names
|
||||
hasManyExpectations := []string{
|
||||
"RelRIDAPIProviderOrgLogins", // Has many via Login
|
||||
"RelRIDAPIProviderOrgFilepointers", // Has many via Filepointer
|
||||
"RelRIDAPIProviderOrgAPIEvents", // Has many via APIEvent
|
||||
"RelRIDOwner", // Has one via rid_owner
|
||||
}
|
||||
|
||||
for _, exp := range hasManyExpectations {
|
||||
if !strings.Contains(contentStr, exp) {
|
||||
t.Errorf("Missing relationship field: %s\nGenerated:\n%s", exp, contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify NO duplicate field names
|
||||
// Count occurrences of "RelRIDAPIProvider" fields - should have 3 unique ones
|
||||
count := strings.Count(contentStr, "RelRIDAPIProvider")
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 RelRIDAPIProvider* fields, found %d\nGenerated:\n%s", count, contentStr)
|
||||
}
|
||||
|
||||
// Verify no duplicate declarations (would cause compilation error)
|
||||
duplicatePattern := "RelRIDAPIProviders []*Model"
|
||||
if strings.Contains(contentStr, duplicatePattern) {
|
||||
t.Errorf("Found duplicate field declaration pattern, fields should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_FieldNameCollision(t *testing.T) {
|
||||
// Test scenario: table with columns that would conflict with generated method names
|
||||
table := models.InitTable("audit_table", "audit")
|
||||
table.Columns["id_audit_table"] = &models.Column{
|
||||
Name: "id_audit_table",
|
||||
Type: "smallint",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
Sequence: 1,
|
||||
}
|
||||
table.Columns["table_name"] = &models.Column{
|
||||
Name: "table_name",
|
||||
Type: "varchar",
|
||||
Length: 100,
|
||||
NotNull: true,
|
||||
Sequence: 2,
|
||||
}
|
||||
table.Columns["table_schema"] = &models.Column{
|
||||
Name: "table_schema",
|
||||
Type: "varchar",
|
||||
Length: 100,
|
||||
NotNull: true,
|
||||
Sequence: 3,
|
||||
}
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the generated file
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
// Verify that TableName field was renamed to TableName_ to avoid collision
|
||||
if !strings.Contains(generated, "TableName_") {
|
||||
t.Errorf("Expected field 'TableName_' (with underscore) but not found\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify the struct tag still references the correct database column
|
||||
if !strings.Contains(generated, `bun:"table_name,`) {
|
||||
t.Errorf("Expected bun tag to reference 'table_name' column\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify the TableName() method still exists and doesn't conflict
|
||||
if !strings.Contains(generated, "func (m ModelAuditAuditTable) TableName() string") {
|
||||
t.Errorf("TableName() method should still be generated\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify NO field named just "TableName" (without underscore)
|
||||
if strings.Contains(generated, "TableName resolvespec_common") || strings.Contains(generated, "TableName string") {
|
||||
t.Errorf("Field 'TableName' without underscore should not exist (would conflict with method)\nGenerated:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
|
||||
|
||||
@@ -126,7 +126,15 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
attrs = append(attrs, "increment")
|
||||
}
|
||||
if column.Default != nil {
|
||||
attrs = append(attrs, fmt.Sprintf("default: `%v`", column.Default))
|
||||
// Check if default value contains backticks (DBML expressions like `now()`)
|
||||
defaultStr := fmt.Sprintf("%v", column.Default)
|
||||
if strings.HasPrefix(defaultStr, "`") && strings.HasSuffix(defaultStr, "`") {
|
||||
// Already an expression with backticks, use as-is
|
||||
attrs = append(attrs, fmt.Sprintf("default: %s", defaultStr))
|
||||
} else {
|
||||
// Regular value, wrap in single quotes
|
||||
attrs = append(attrs, fmt.Sprintf("default: '%v'", column.Default))
|
||||
}
|
||||
}
|
||||
|
||||
if len(attrs) > 0 {
|
||||
|
||||
@@ -133,7 +133,11 @@ func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
|
||||
prefix = table.Name[:3]
|
||||
}
|
||||
|
||||
tableGuid := w.newGUID()
|
||||
// Use GUID from model if available, otherwise generate a new one
|
||||
tableGuid := table.GUID
|
||||
if tableGuid == "" {
|
||||
tableGuid = w.newGUID()
|
||||
}
|
||||
w.tableGuidMap[table.Name] = tableGuid
|
||||
|
||||
dctxTable := models.DCTXTable{
|
||||
@@ -171,7 +175,11 @@ func (w *Writer) mapTableKeys(table *models.Table) []models.DCTXKey {
|
||||
}
|
||||
|
||||
func (w *Writer) mapField(column *models.Column) models.DCTXField {
|
||||
guid := w.newGUID()
|
||||
// Use GUID from model if available, otherwise generate a new one
|
||||
guid := column.GUID
|
||||
if guid == "" {
|
||||
guid = w.newGUID()
|
||||
}
|
||||
fieldKey := fmt.Sprintf("%s.%s", column.Table, column.Name)
|
||||
w.fieldGuidMap[fieldKey] = guid
|
||||
|
||||
@@ -209,7 +217,11 @@ func (w *Writer) mapDataType(dataType string) string {
|
||||
}
|
||||
|
||||
func (w *Writer) mapKey(index *models.Index, table *models.Table) models.DCTXKey {
|
||||
guid := w.newGUID()
|
||||
// Use GUID from model if available, otherwise generate a new one
|
||||
guid := index.GUID
|
||||
if guid == "" {
|
||||
guid = w.newGUID()
|
||||
}
|
||||
keyKey := fmt.Sprintf("%s.%s", table.Name, index.Name)
|
||||
w.keyGuidMap[keyKey] = guid
|
||||
|
||||
@@ -344,7 +356,7 @@ func (w *Writer) mapRelation(rel *models.Relationship, schema *models.Schema) mo
|
||||
}
|
||||
|
||||
return models.DCTXRelation{
|
||||
Guid: w.newGUID(),
|
||||
Guid: rel.GUID, // Use GUID from relationship model
|
||||
PrimaryTable: w.tableGuidMap[rel.ToTable], // GUID of the 'to' table (e.g., users)
|
||||
ForeignTable: w.tableGuidMap[rel.FromTable], // GUID of the 'from' table (e.g., posts)
|
||||
PrimaryKey: primaryKeyGUID,
|
||||
|
||||
@@ -196,7 +196,9 @@ func (w *Writer) writeTableFile(table *models.Table, schema *models.Schema, db *
|
||||
}
|
||||
|
||||
// Generate filename: {tableName}.ts
|
||||
filename := filepath.Join(w.options.OutputPath, table.Name+".ts")
|
||||
// Sanitize table name to remove quotes, comments, and invalid characters
|
||||
safeTableName := writers.SanitizeFilename(table.Name)
|
||||
filename := filepath.Join(w.options.OutputPath, safeTableName+".ts")
|
||||
return os.WriteFile(filename, []byte(code), 0644)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"sort"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// TemplateData represents the data passed to the template for code generation
|
||||
@@ -24,6 +25,7 @@ type ModelData struct {
|
||||
Fields []*FieldData
|
||||
Config *MethodConfig
|
||||
PrimaryKeyField string // Name of the primary key field
|
||||
PrimaryKeyType string // Go type of the primary key field
|
||||
IDColumnName string // Name of the ID column in database
|
||||
Prefix string // 3-letter prefix
|
||||
}
|
||||
@@ -109,13 +111,17 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
tableName = schema + "." + table.Name
|
||||
}
|
||||
|
||||
// Generate model name: singularize and convert to PascalCase
|
||||
// Generate model name: Model + Schema + Table (all PascalCase)
|
||||
singularTable := Singularize(table.Name)
|
||||
modelName := SnakeCaseToPascalCase(singularTable)
|
||||
tablePart := SnakeCaseToPascalCase(singularTable)
|
||||
|
||||
// Add "Model" prefix if not already present
|
||||
if !hasModelPrefix(modelName) {
|
||||
modelName = "Model" + modelName
|
||||
// Include schema name in model name
|
||||
var modelName string
|
||||
if schema != "" {
|
||||
schemaPart := SnakeCaseToPascalCase(schema)
|
||||
modelName = "Model" + schemaPart + tablePart
|
||||
} else {
|
||||
modelName = "Model" + tablePart
|
||||
}
|
||||
|
||||
model := &ModelData{
|
||||
@@ -131,8 +137,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
// Find primary key
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
model.PrimaryKeyField = SnakeCaseToPascalCase(col.Name)
|
||||
model.IDColumnName = col.Name
|
||||
// Sanitize column name to remove backticks
|
||||
safeName := writers.SanitizeStructTagValue(col.Name)
|
||||
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
|
||||
model.PrimaryKeyType = typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
model.IDColumnName = safeName
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -141,6 +150,8 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
columns := sortColumns(table.Columns)
|
||||
for _, col := range columns {
|
||||
field := columnToField(col, table, typeMapper)
|
||||
// Check for name collision with generated methods and rename if needed
|
||||
field.Name = resolveFieldNameCollision(field.Name)
|
||||
model.Fields = append(model.Fields, field)
|
||||
}
|
||||
|
||||
@@ -149,10 +160,13 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper) *M
|
||||
|
||||
// columnToField converts a models.Column to FieldData
|
||||
func columnToField(col *models.Column, table *models.Table, typeMapper *TypeMapper) *FieldData {
|
||||
fieldName := SnakeCaseToPascalCase(col.Name)
|
||||
// Sanitize column name first to remove backticks before generating field name
|
||||
safeName := writers.SanitizeStructTagValue(col.Name)
|
||||
fieldName := SnakeCaseToPascalCase(safeName)
|
||||
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
|
||||
gormTag := typeMapper.BuildGormTag(col, table)
|
||||
jsonTag := col.Name // Use column name for JSON tag
|
||||
// Use same sanitized name for JSON tag
|
||||
jsonTag := safeName
|
||||
|
||||
return &FieldData{
|
||||
Name: fieldName,
|
||||
@@ -179,9 +193,28 @@ func formatComment(description, comment string) string {
|
||||
return comment
|
||||
}
|
||||
|
||||
// hasModelPrefix checks if a name already has "Model" prefix
|
||||
func hasModelPrefix(name string) bool {
|
||||
return len(name) >= 5 && name[:5] == "Model"
|
||||
// resolveFieldNameCollision checks if a field name conflicts with generated method names
|
||||
// and adds an underscore suffix if there's a collision
|
||||
func resolveFieldNameCollision(fieldName string) string {
|
||||
// List of method names that are generated by the template
|
||||
reservedNames := map[string]bool{
|
||||
"TableName": true,
|
||||
"TableNameOnly": true,
|
||||
"SchemaName": true,
|
||||
"GetID": true,
|
||||
"GetIDStr": true,
|
||||
"SetID": true,
|
||||
"UpdateID": true,
|
||||
"GetIDName": true,
|
||||
"GetPrefix": true,
|
||||
}
|
||||
|
||||
// Check if field name conflicts with a reserved method name
|
||||
if reservedNames[fieldName] {
|
||||
return fieldName + "_"
|
||||
}
|
||||
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// sortColumns sorts columns by sequence, then by name
|
||||
|
||||
@@ -62,7 +62,7 @@ func (m {{.Name}}) SetID(newid int64) {
|
||||
{{if and .Config.GenerateUpdateID .PrimaryKeyField}}
|
||||
// UpdateID updates the primary key value
|
||||
func (m *{{.Name}}) UpdateID(newid int64) {
|
||||
m.{{.PrimaryKeyField}} = int32(newid)
|
||||
m.{{.PrimaryKeyField}} = {{.PrimaryKeyType}}(newid)
|
||||
}
|
||||
{{end}}
|
||||
{{if and .Config.GenerateGetIDName .IDColumnName}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// TypeMapper handles type conversions between SQL and Go types
|
||||
@@ -199,12 +200,15 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
|
||||
var parts []string
|
||||
|
||||
// Always include column name (lowercase as per user requirement)
|
||||
parts = append(parts, fmt.Sprintf("column:%s", column.Name))
|
||||
// Sanitize to remove backticks which would break struct tag syntax
|
||||
safeName := writers.SanitizeStructTagValue(column.Name)
|
||||
parts = append(parts, fmt.Sprintf("column:%s", safeName))
|
||||
|
||||
// Add type if specified
|
||||
if column.Type != "" {
|
||||
// Include length, precision, scale if present
|
||||
typeStr := column.Type
|
||||
// Sanitize type to remove backticks
|
||||
typeStr := writers.SanitizeStructTagValue(column.Type)
|
||||
if column.Length > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
|
||||
} else if column.Precision > 0 {
|
||||
@@ -234,7 +238,9 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
|
||||
|
||||
// Default value
|
||||
if column.Default != nil {
|
||||
parts = append(parts, fmt.Sprintf("default:%v", column.Default))
|
||||
// Sanitize default value to remove backticks
|
||||
safeDefault := writers.SanitizeStructTagValue(fmt.Sprintf("%v", column.Default))
|
||||
parts = append(parts, fmt.Sprintf("default:%s", safeDefault))
|
||||
}
|
||||
|
||||
// Check for unique constraint
|
||||
@@ -331,5 +337,5 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
|
||||
|
||||
// GetSQLTypesImport returns the import path for sql_types
|
||||
func (tm *TypeMapper) GetSQLTypesImport() string {
|
||||
return "github.com/bitechdev/ResolveSpec/pkg/common/sql_types"
|
||||
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -121,7 +122,16 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
}
|
||||
|
||||
// Write output
|
||||
return w.writeOutput(formatted)
|
||||
if err := w.writeOutput(formatted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run go fmt on the output file
|
||||
if w.options.OutputPath != "" {
|
||||
w.runGoFmt(w.options.OutputPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMultiFile writes each table to a separate file
|
||||
@@ -201,13 +211,19 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
}
|
||||
|
||||
// Generate filename: sql_{schema}_{table}.go
|
||||
filename := fmt.Sprintf("sql_%s_%s.go", schema.Name, table.Name)
|
||||
// Sanitize schema and table names to remove quotes, comments, and invalid characters
|
||||
safeSchemaName := writers.SanitizeFilename(schema.Name)
|
||||
safeTableName := writers.SanitizeFilename(table.Name)
|
||||
filename := fmt.Sprintf("sql_%s_%s.go", safeSchemaName, safeTableName)
|
||||
filepath := filepath.Join(w.options.OutputPath, filename)
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(filepath, []byte(formatted), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Run go fmt on the generated file
|
||||
w.runGoFmt(filepath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +232,9 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
|
||||
// addRelationshipFields adds relationship fields to the model based on foreign keys
|
||||
func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table, schema *models.Schema, db *models.Database) {
|
||||
// Track used field names to detect duplicates
|
||||
usedFieldNames := make(map[string]int)
|
||||
|
||||
// For each foreign key in this table, add a belongs-to relationship
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
@@ -229,8 +248,9 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
}
|
||||
|
||||
// Create relationship field (belongs-to)
|
||||
refModelName := w.getModelName(constraint.ReferencedTable)
|
||||
fieldName := w.generateRelationshipFieldName(constraint.ReferencedTable)
|
||||
refModelName := w.getModelName(constraint.ReferencedSchema, constraint.ReferencedTable)
|
||||
fieldName := w.generateBelongsToFieldName(constraint)
|
||||
fieldName = w.ensureUniqueFieldName(fieldName, usedFieldNames)
|
||||
relationTag := w.typeMapper.BuildRelationshipTag(constraint, false)
|
||||
|
||||
modelData.AddRelationshipField(&FieldData{
|
||||
@@ -257,8 +277,9 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
// Check if this constraint references our table
|
||||
if constraint.ReferencedTable == table.Name && constraint.ReferencedSchema == schema.Name {
|
||||
// Add has-many relationship
|
||||
otherModelName := w.getModelName(otherTable.Name)
|
||||
fieldName := w.generateRelationshipFieldName(otherTable.Name) + "s" // Pluralize
|
||||
otherModelName := w.getModelName(otherSchema.Name, otherTable.Name)
|
||||
fieldName := w.generateHasManyFieldName(constraint, otherSchema.Name, otherTable.Name)
|
||||
fieldName = w.ensureUniqueFieldName(fieldName, usedFieldNames)
|
||||
relationTag := w.typeMapper.BuildRelationshipTag(constraint, true)
|
||||
|
||||
modelData.AddRelationshipField(&FieldData{
|
||||
@@ -289,22 +310,77 @@ func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *m
|
||||
return nil
|
||||
}
|
||||
|
||||
// getModelName generates the model name from a table name
|
||||
func (w *Writer) getModelName(tableName string) string {
|
||||
// getModelName generates the model name from schema and table name
|
||||
func (w *Writer) getModelName(schemaName, tableName string) string {
|
||||
singular := Singularize(tableName)
|
||||
modelName := SnakeCaseToPascalCase(singular)
|
||||
tablePart := SnakeCaseToPascalCase(singular)
|
||||
|
||||
if !hasModelPrefix(modelName) {
|
||||
modelName = "Model" + modelName
|
||||
// Include schema name in model name
|
||||
var modelName string
|
||||
if schemaName != "" {
|
||||
schemaPart := SnakeCaseToPascalCase(schemaName)
|
||||
modelName = "Model" + schemaPart + tablePart
|
||||
} else {
|
||||
modelName = "Model" + tablePart
|
||||
}
|
||||
|
||||
return modelName
|
||||
}
|
||||
|
||||
// generateRelationshipFieldName generates a field name for a relationship
|
||||
func (w *Writer) generateRelationshipFieldName(tableName string) string {
|
||||
// Use just the prefix (3 letters) for relationship fields
|
||||
return GeneratePrefix(tableName)
|
||||
// generateBelongsToFieldName generates a field name for belongs-to relationships
|
||||
// Uses the foreign key column name for uniqueness
|
||||
func (w *Writer) generateBelongsToFieldName(constraint *models.Constraint) string {
|
||||
// Use the foreign key column name to ensure uniqueness
|
||||
// If there are multiple columns, use the first one
|
||||
if len(constraint.Columns) > 0 {
|
||||
columnName := constraint.Columns[0]
|
||||
// Convert to PascalCase for proper Go field naming
|
||||
// e.g., "rid_filepointer_request" -> "RelRIDFilepointerRequest"
|
||||
return "Rel" + SnakeCaseToPascalCase(columnName)
|
||||
}
|
||||
|
||||
// Fallback to table-based prefix if no columns defined
|
||||
return "Rel" + GeneratePrefix(constraint.ReferencedTable)
|
||||
}
|
||||
|
||||
// generateHasManyFieldName generates a field name for has-many relationships
|
||||
// Uses the foreign key column name + source table name to avoid duplicates
|
||||
func (w *Writer) generateHasManyFieldName(constraint *models.Constraint, sourceSchemaName, sourceTableName string) string {
|
||||
// For has-many, we need to include the source table name to avoid duplicates
|
||||
// e.g., multiple tables referencing the same column on this table
|
||||
if len(constraint.Columns) > 0 {
|
||||
columnName := constraint.Columns[0]
|
||||
// Get the model name for the source table (pluralized)
|
||||
sourceModelName := w.getModelName(sourceSchemaName, sourceTableName)
|
||||
// Remove "Model" prefix if present
|
||||
sourceModelName = strings.TrimPrefix(sourceModelName, "Model")
|
||||
|
||||
// Convert column to PascalCase and combine with source table
|
||||
// e.g., "rid_api_provider" + "Login" -> "RelRIDAPIProviderLogins"
|
||||
columnPart := SnakeCaseToPascalCase(columnName)
|
||||
return "Rel" + columnPart + Pluralize(sourceModelName)
|
||||
}
|
||||
|
||||
// Fallback to table-based naming
|
||||
sourceModelName := w.getModelName(sourceSchemaName, sourceTableName)
|
||||
sourceModelName = strings.TrimPrefix(sourceModelName, "Model")
|
||||
return "Rel" + Pluralize(sourceModelName)
|
||||
}
|
||||
|
||||
// ensureUniqueFieldName ensures a field name is unique by adding numeric suffixes if needed
|
||||
func (w *Writer) ensureUniqueFieldName(fieldName string, usedNames map[string]int) string {
|
||||
originalName := fieldName
|
||||
count := usedNames[originalName]
|
||||
|
||||
if count > 0 {
|
||||
// Name is already used, add numeric suffix
|
||||
fieldName = fmt.Sprintf("%s%d", originalName, count+1)
|
||||
}
|
||||
|
||||
// Increment the counter for this base name
|
||||
usedNames[originalName]++
|
||||
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// getPackageName returns the package name from options or defaults to "models"
|
||||
@@ -335,6 +411,15 @@ func (w *Writer) writeOutput(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runGoFmt runs go fmt on the specified file
|
||||
func (w *Writer) runGoFmt(filepath string) {
|
||||
cmd := exec.Command("gofmt", "-w", filepath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Don't fail the whole operation if gofmt fails, just warn
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to run gofmt on %s: %v\n", filepath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUseMultiFile determines whether to use multi-file mode based on metadata or output path
|
||||
func (w *Writer) shouldUseMultiFile() bool {
|
||||
// Check if multi_file is explicitly set in metadata
|
||||
@@ -380,6 +465,7 @@ func (w *Writer) createDatabaseRef(db *models.Database) *models.Database {
|
||||
DatabaseVersion: db.DatabaseVersion,
|
||||
SourceFormat: db.SourceFormat,
|
||||
Schemas: nil, // Don't include schemas to avoid circular reference
|
||||
GUID: db.GUID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,5 +482,6 @@ func (w *Writer) createSchemaRef(schema *models.Schema, db *models.Database) *mo
|
||||
Sequence: schema.Sequence,
|
||||
RefDatabase: w.createDatabaseRef(db), // Include database ref
|
||||
Tables: nil, // Don't include tables to avoid circular reference
|
||||
GUID: schema.GUID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
// Verify key elements are present
|
||||
expectations := []string{
|
||||
"package models",
|
||||
"type ModelUser struct",
|
||||
"type ModelPublicUser struct",
|
||||
"ID",
|
||||
"int64",
|
||||
"Email",
|
||||
@@ -75,9 +75,9 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
"time.Time",
|
||||
"gorm:\"column:id",
|
||||
"gorm:\"column:email",
|
||||
"func (m ModelUser) TableName() string",
|
||||
"func (m ModelPublicUser) TableName() string",
|
||||
"return \"public.users\"",
|
||||
"func (m ModelUser) GetID() int64",
|
||||
"func (m ModelPublicUser) GetID() int64",
|
||||
}
|
||||
|
||||
for _, expected := range expectations {
|
||||
@@ -164,9 +164,437 @@ func TestWriter_WriteDatabase_MultiFile(t *testing.T) {
|
||||
t.Fatalf("Failed to read posts file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(postsContent), "USE *ModelUser") {
|
||||
// Relationship field should be present
|
||||
t.Logf("Posts content:\n%s", string(postsContent))
|
||||
postsStr := string(postsContent)
|
||||
|
||||
// Verify relationship is present with new naming convention
|
||||
// Should now be RelUserID (belongs-to) instead of USE
|
||||
if !strings.Contains(postsStr, "RelUserID") {
|
||||
t.Errorf("Missing relationship field RelUserID (new naming convention)")
|
||||
}
|
||||
|
||||
// Check users file contains has-many relationship
|
||||
usersContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_public_users.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read users file: %v", err)
|
||||
}
|
||||
|
||||
usersStr := string(usersContent)
|
||||
|
||||
// Should have RelUserIDPublicPosts (has-many) field - includes schema prefix
|
||||
if !strings.Contains(usersStr, "RelUserIDPublicPosts") {
|
||||
t.Errorf("Missing has-many relationship field RelUserIDPublicPosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_MultipleReferencesToSameTable(t *testing.T) {
|
||||
// Test scenario: api_event table with multiple foreign keys to filepointer table
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("org")
|
||||
|
||||
// Filepointer table
|
||||
filepointer := models.InitTable("filepointer", "org")
|
||||
filepointer.Columns["id_filepointer"] = &models.Column{
|
||||
Name: "id_filepointer",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
schema.Tables = append(schema.Tables, filepointer)
|
||||
|
||||
// API event table with two foreign keys to filepointer
|
||||
apiEvent := models.InitTable("api_event", "org")
|
||||
apiEvent.Columns["id_api_event"] = &models.Column{
|
||||
Name: "id_api_event",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiEvent.Columns["rid_filepointer_request"] = &models.Column{
|
||||
Name: "rid_filepointer_request",
|
||||
Type: "bigint",
|
||||
NotNull: false,
|
||||
}
|
||||
apiEvent.Columns["rid_filepointer_response"] = &models.Column{
|
||||
Name: "rid_filepointer_response",
|
||||
Type: "bigint",
|
||||
NotNull: false,
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
apiEvent.Constraints["fk_request"] = &models.Constraint{
|
||||
Name: "fk_request",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_filepointer_request"},
|
||||
ReferencedTable: "filepointer",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_filepointer"},
|
||||
}
|
||||
apiEvent.Constraints["fk_response"] = &models.Constraint{
|
||||
Name: "fk_response",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_filepointer_response"},
|
||||
ReferencedTable: "filepointer",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_filepointer"},
|
||||
}
|
||||
|
||||
schema.Tables = append(schema.Tables, apiEvent)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: tmpDir,
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true,
|
||||
},
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the api_event file
|
||||
apiEventContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_api_event.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read api_event file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(apiEventContent)
|
||||
|
||||
// Verify both relationships have unique names based on column names
|
||||
expectations := []struct {
|
||||
fieldName string
|
||||
tag string
|
||||
}{
|
||||
{"RelRIDFilepointerRequest", "foreignKey:RIDFilepointerRequest"},
|
||||
{"RelRIDFilepointerResponse", "foreignKey:RIDFilepointerResponse"},
|
||||
}
|
||||
|
||||
for _, exp := range expectations {
|
||||
if !strings.Contains(contentStr, exp.fieldName) {
|
||||
t.Errorf("Missing relationship field: %s\nGenerated:\n%s", exp.fieldName, contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, exp.tag) {
|
||||
t.Errorf("Missing relationship tag: %s\nGenerated:\n%s", exp.tag, contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify NO duplicate field names (old behavior would create duplicate "FIL" fields)
|
||||
if strings.Contains(contentStr, "FIL *ModelFilepointer") {
|
||||
t.Errorf("Found old prefix-based naming (FIL), should use column-based naming")
|
||||
}
|
||||
|
||||
// Also verify has-many relationships on filepointer table
|
||||
filepointerContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_filepointer.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read filepointer file: %v", err)
|
||||
}
|
||||
|
||||
filepointerStr := string(filepointerContent)
|
||||
|
||||
// Should have two different has-many relationships with unique names
|
||||
hasManyExpectations := []string{
|
||||
"RelRIDFilepointerRequestOrgAPIEvents", // Has many via rid_filepointer_request
|
||||
"RelRIDFilepointerResponseOrgAPIEvents", // Has many via rid_filepointer_response
|
||||
}
|
||||
|
||||
for _, exp := range hasManyExpectations {
|
||||
if !strings.Contains(filepointerStr, exp) {
|
||||
t.Errorf("Missing has-many relationship field: %s\nGenerated:\n%s", exp, filepointerStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_MultipleHasManyRelationships(t *testing.T) {
|
||||
// Test scenario: api_provider table referenced by multiple tables via rid_api_provider
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("org")
|
||||
|
||||
// Owner table
|
||||
owner := models.InitTable("owner", "org")
|
||||
owner.Columns["id_owner"] = &models.Column{
|
||||
Name: "id_owner",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
schema.Tables = append(schema.Tables, owner)
|
||||
|
||||
// API Provider table
|
||||
apiProvider := models.InitTable("api_provider", "org")
|
||||
apiProvider.Columns["id_api_provider"] = &models.Column{
|
||||
Name: "id_api_provider",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiProvider.Columns["rid_owner"] = &models.Column{
|
||||
Name: "rid_owner",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
apiProvider.Constraints["fk_owner"] = &models.Constraint{
|
||||
Name: "fk_owner",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_owner"},
|
||||
ReferencedTable: "owner",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_owner"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, apiProvider)
|
||||
|
||||
// Login table
|
||||
login := models.InitTable("login", "org")
|
||||
login.Columns["id_login"] = &models.Column{
|
||||
Name: "id_login",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
login.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
login.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, login)
|
||||
|
||||
// Filepointer table
|
||||
filepointer := models.InitTable("filepointer", "org")
|
||||
filepointer.Columns["id_filepointer"] = &models.Column{
|
||||
Name: "id_filepointer",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
filepointer.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
filepointer.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, filepointer)
|
||||
|
||||
// API Event table
|
||||
apiEvent := models.InitTable("api_event", "org")
|
||||
apiEvent.Columns["id_api_event"] = &models.Column{
|
||||
Name: "id_api_event",
|
||||
Type: "bigserial",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
apiEvent.Columns["rid_api_provider"] = &models.Column{
|
||||
Name: "rid_api_provider",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
}
|
||||
apiEvent.Constraints["fk_api_provider"] = &models.Constraint{
|
||||
Name: "fk_api_provider",
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Columns: []string{"rid_api_provider"},
|
||||
ReferencedTable: "api_provider",
|
||||
ReferencedSchema: "org",
|
||||
ReferencedColumns: []string{"id_api_provider"},
|
||||
}
|
||||
schema.Tables = append(schema.Tables, apiEvent)
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: tmpDir,
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true,
|
||||
},
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the api_provider file
|
||||
apiProviderContent, err := os.ReadFile(filepath.Join(tmpDir, "sql_org_api_provider.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read api_provider file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(apiProviderContent)
|
||||
|
||||
// Verify all has-many relationships have unique names
|
||||
hasManyExpectations := []string{
|
||||
"RelRIDAPIProviderOrgLogins", // Has many via Login
|
||||
"RelRIDAPIProviderOrgFilepointers", // Has many via Filepointer
|
||||
"RelRIDAPIProviderOrgAPIEvents", // Has many via APIEvent
|
||||
"RelRIDOwner", // Belongs to via rid_owner
|
||||
}
|
||||
|
||||
for _, exp := range hasManyExpectations {
|
||||
if !strings.Contains(contentStr, exp) {
|
||||
t.Errorf("Missing relationship field: %s\nGenerated:\n%s", exp, contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify NO duplicate field names
|
||||
// Count occurrences of "RelRIDAPIProvider" fields - should have 3 unique ones
|
||||
count := strings.Count(contentStr, "RelRIDAPIProvider")
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 RelRIDAPIProvider* fields, found %d\nGenerated:\n%s", count, contentStr)
|
||||
}
|
||||
|
||||
// Verify no duplicate declarations (would cause compilation error)
|
||||
duplicatePattern := "RelRIDAPIProviders []*Model"
|
||||
if strings.Contains(contentStr, duplicatePattern) {
|
||||
t.Errorf("Found duplicate field declaration pattern, fields should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_FieldNameCollision(t *testing.T) {
|
||||
// Test scenario: table with columns that would conflict with generated method names
|
||||
table := models.InitTable("audit_table", "audit")
|
||||
table.Columns["id_audit_table"] = &models.Column{
|
||||
Name: "id_audit_table",
|
||||
Type: "smallint",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
Sequence: 1,
|
||||
}
|
||||
table.Columns["table_name"] = &models.Column{
|
||||
Name: "table_name",
|
||||
Type: "varchar",
|
||||
Length: 100,
|
||||
NotNull: true,
|
||||
Sequence: 2,
|
||||
}
|
||||
table.Columns["table_schema"] = &models.Column{
|
||||
Name: "table_schema",
|
||||
Type: "varchar",
|
||||
Length: 100,
|
||||
NotNull: true,
|
||||
Sequence: 3,
|
||||
}
|
||||
|
||||
// Create writer
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the generated file
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
// Verify that TableName field was renamed to TableName_ to avoid collision
|
||||
if !strings.Contains(generated, "TableName_") {
|
||||
t.Errorf("Expected field 'TableName_' (with underscore) but not found\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify the struct tag still references the correct database column
|
||||
if !strings.Contains(generated, `gorm:"column:table_name;`) {
|
||||
t.Errorf("Expected gorm tag to reference 'table_name' column\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify the TableName() method still exists and doesn't conflict
|
||||
if !strings.Contains(generated, "func (m ModelAuditAuditTable) TableName() string") {
|
||||
t.Errorf("TableName() method should still be generated\nGenerated:\n%s", generated)
|
||||
}
|
||||
|
||||
// Verify NO field named just "TableName" (without underscore)
|
||||
if strings.Contains(generated, "TableName sql_types") || strings.Contains(generated, "TableName string") {
|
||||
t.Errorf("Field 'TableName' without underscore should not exist (would conflict with method)\nGenerated:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_UpdateIDTypeSafety(t *testing.T) {
|
||||
// Test scenario: tables with different primary key types
|
||||
tests := []struct {
|
||||
name string
|
||||
pkType string
|
||||
expectedPK string
|
||||
castType string
|
||||
}{
|
||||
{"int32_pk", "int", "int32", "int32(newid)"},
|
||||
{"int16_pk", "smallint", "int16", "int16(newid)"},
|
||||
{"int64_pk", "bigint", "int64", "int64(newid)"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
table := models.InitTable("test_table", "public")
|
||||
table.Columns["id"] = &models.Column{
|
||||
Name: "id",
|
||||
Type: tt.pkType,
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
opts := &writers.WriterOptions{
|
||||
PackageName: "models",
|
||||
OutputPath: filepath.Join(tmpDir, "test.go"),
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
err := writer.WriteTable(table)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteTable failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(opts.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
generated := string(content)
|
||||
|
||||
// Verify UpdateID method has correct type cast
|
||||
if !strings.Contains(generated, tt.castType) {
|
||||
t.Errorf("Expected UpdateID to cast to %s\nGenerated:\n%s", tt.castType, generated)
|
||||
}
|
||||
|
||||
// Verify no invalid int32(newid) for non-int32 types
|
||||
if tt.expectedPK != "int32" && strings.Contains(generated, "int32(newid)") {
|
||||
t.Errorf("UpdateID should not cast to int32 for %s type\nGenerated:\n%s", tt.pkType, generated)
|
||||
}
|
||||
|
||||
// Verify UpdateID parameter is int64 (for consistency)
|
||||
if !strings.Contains(generated, "UpdateID(newid int64)") {
|
||||
t.Errorf("UpdateID should accept int64 parameter\nGenerated:\n%s", generated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
217
pkg/writers/pgsql/NAMING_CONVENTIONS.md
Normal file
217
pkg/writers/pgsql/NAMING_CONVENTIONS.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# PostgreSQL Naming Conventions
|
||||
|
||||
Standardized naming rules for all database objects in RelSpec PostgreSQL output.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Object Type | Prefix | Format | Example |
|
||||
| ----------------- | ----------- | ---------------------------------- | ------------------------ |
|
||||
| Primary Key | `pk_` | `pk_<schema>_<table>` | `pk_public_users` |
|
||||
| Foreign Key | `fk_` | `fk_<table>_<referenced_table>` | `fk_posts_users` |
|
||||
| Unique Constraint | `uk_` | `uk_<table>_<column>` | `uk_users_email` |
|
||||
| Unique Index | `uidx_` | `uidx_<table>_<column>` | `uidx_users_email` |
|
||||
| Regular Index | `idx_` | `idx_<table>_<column>` | `idx_posts_user_id` |
|
||||
| Check Constraint | `chk_` | `chk_<table>_<constraint_purpose>` | `chk_users_age_positive` |
|
||||
| Sequence | `identity_` | `identity_<table>_<column>` | `identity_users_id` |
|
||||
| Trigger | `t_` | `t_<purpose>_<table>` | `t_audit_users` |
|
||||
| Trigger Function | `tf_` | `tf_<purpose>_<table>` | `tf_audit_users` |
|
||||
|
||||
## Naming Rules by Object Type
|
||||
|
||||
### Primary Keys
|
||||
|
||||
**Pattern:** `pk_<schema>_<table>`
|
||||
|
||||
- Include schema name to avoid collisions across schemas
|
||||
- Use lowercase, snake_case format
|
||||
- Examples:
|
||||
- `pk_public_users`
|
||||
- `pk_audit_audit_log`
|
||||
- `pk_staging_temp_data`
|
||||
|
||||
### Foreign Keys
|
||||
|
||||
**Pattern:** `fk_<table>_<referenced_table>`
|
||||
|
||||
- Reference the table containing the FK followed by the referenced table
|
||||
- Use lowercase, snake_case format
|
||||
- Do NOT include column names in standard FK constraints
|
||||
- Examples:
|
||||
- `fk_posts_users` (posts.user_id → users.id)
|
||||
- `fk_comments_posts` (comments.post_id → posts.id)
|
||||
- `fk_order_items_orders` (order_items.order_id → orders.id)
|
||||
|
||||
### Unique Constraints
|
||||
|
||||
**Pattern:** `uk_<table>_<column>`
|
||||
|
||||
- Use `uk_` prefix strictly for database constraints (CONSTRAINT type)
|
||||
- Include column name for clarity
|
||||
- Examples:
|
||||
- `uk_users_email`
|
||||
- `uk_users_username`
|
||||
- `uk_products_sku`
|
||||
|
||||
### Unique Indexes
|
||||
|
||||
**Pattern:** `uidx_<table>_<column>`
|
||||
|
||||
- Use `uidx_` prefix strictly for index type objects
|
||||
- Distinguished from constraints for clarity and implementation flexibility
|
||||
- Examples:
|
||||
- `uidx_users_email`
|
||||
- `uidx_sessions_token`
|
||||
- `uidx_api_keys_key`
|
||||
|
||||
### Regular Indexes
|
||||
|
||||
**Pattern:** `idx_<table>_<column>`
|
||||
|
||||
- Standard indexes for query optimization
|
||||
- Single column: `idx_<table>_<column>`
|
||||
- Examples:
|
||||
- `idx_posts_user_id`
|
||||
- `idx_orders_created_at`
|
||||
- `idx_users_status`
|
||||
|
||||
### Check Constraints
|
||||
|
||||
**Pattern:** `chk_<table>_<constraint_purpose>`
|
||||
|
||||
- Describe the constraint validation purpose
|
||||
- Use lowercase, snake_case for the purpose
|
||||
- Examples:
|
||||
- `chk_users_age_positive` (CHECK (age > 0))
|
||||
- `chk_orders_quantity_positive` (CHECK (quantity > 0))
|
||||
- `chk_products_price_valid` (CHECK (price >= 0))
|
||||
- `chk_users_status_enum` (CHECK (status IN ('active', 'inactive')))
|
||||
|
||||
### Sequences
|
||||
|
||||
**Pattern:** `identity_<table>_<column>`
|
||||
|
||||
- Used for SERIAL/IDENTITY columns
|
||||
- Explicitly named for clarity and management
|
||||
- Examples:
|
||||
- `identity_users_id`
|
||||
- `identity_posts_id`
|
||||
- `identity_transactions_id`
|
||||
|
||||
### Triggers
|
||||
|
||||
**Pattern:** `t_<purpose>_<table>`
|
||||
|
||||
- Include purpose before table name
|
||||
- Lowercase, snake_case format
|
||||
- Examples:
|
||||
- `t_audit_users` (audit trigger on users table)
|
||||
- `t_update_timestamp_posts` (timestamp update trigger on posts)
|
||||
- `t_validate_orders` (validation trigger on orders)
|
||||
|
||||
### Trigger Functions
|
||||
|
||||
**Pattern:** `tf_<purpose>_<table>`
|
||||
|
||||
- Pair with trigger naming convention
|
||||
- Use `tf_` prefix to distinguish from triggers themselves
|
||||
- Examples:
|
||||
- `tf_audit_users` (function for t_audit_users)
|
||||
- `tf_update_timestamp_posts` (function for t_update_timestamp_posts)
|
||||
- `tf_validate_orders` (function for t_validate_orders)
|
||||
|
||||
## Multi-Column Objects
|
||||
|
||||
### Composite Primary Keys
|
||||
|
||||
**Pattern:** `pk_<schema>_<table>`
|
||||
|
||||
- Same as single-column PKs
|
||||
- Example: `pk_public_order_items` (composite key on order_id + item_id)
|
||||
|
||||
### Composite Unique Constraints
|
||||
|
||||
**Pattern:** `uk_<table>_<column1>_<column2>_[...]`
|
||||
|
||||
- Append all column names in order
|
||||
- Examples:
|
||||
- `uk_users_email_domain` (UNIQUE(email, domain))
|
||||
- `uk_inventory_warehouse_sku` (UNIQUE(warehouse_id, sku))
|
||||
|
||||
### Composite Unique Indexes
|
||||
|
||||
**Pattern:** `uidx_<table>_<column1>_<column2>_[...]`
|
||||
|
||||
- Append all column names in order
|
||||
- Examples:
|
||||
- `uidx_users_first_name_last_name` (UNIQUE INDEX on first_name, last_name)
|
||||
- `uidx_sessions_user_id_device_id` (UNIQUE INDEX on user_id, device_id)
|
||||
|
||||
### Composite Regular Indexes
|
||||
|
||||
**Pattern:** `idx_<table>_<column1>_<column2>_[...]`
|
||||
|
||||
- Append all column names in order
|
||||
- List columns in typical query filter order
|
||||
- Examples:
|
||||
- `idx_orders_user_id_created_at` (filter by user, then sort by created_at)
|
||||
- `idx_logs_level_timestamp` (filter by level, then by timestamp)
|
||||
|
||||
## Special Cases & Conventions
|
||||
|
||||
### Audit Trail Tables
|
||||
|
||||
- Audit table naming: `<original_table>_audit` or `audit_<original_table>`
|
||||
- Audit indexes follow standard pattern: `idx_<audit_table>_<column>`
|
||||
- Examples:
|
||||
- Users table audit: `users_audit` with `idx_users_audit_tablename`, `idx_users_audit_changedate`
|
||||
- Posts table audit: `posts_audit` with `idx_posts_audit_tablename`, `idx_posts_audit_changedate`
|
||||
|
||||
### Temporal/Versioning Tables
|
||||
|
||||
- Use suffix `_history` or `_versions` if needed
|
||||
- Apply standard naming rules with the full table name
|
||||
- Examples:
|
||||
- `idx_users_history_user_id`
|
||||
- `uk_posts_versions_version_number`
|
||||
|
||||
### Schema-Specific Objects
|
||||
|
||||
- Always qualify with schema when needed: `pk_<schema>_<table>`
|
||||
- Multiple schemas allowed: `pk_public_users`, `pk_staging_users`
|
||||
|
||||
### Reserved Words & Special Names
|
||||
|
||||
- Avoid PostgreSQL reserved keywords in object names
|
||||
- If column/table names conflict, use quoted identifiers in DDL
|
||||
- Naming convention rules still apply to the logical name
|
||||
|
||||
### Generated/Anonymous Indexes
|
||||
|
||||
- If an index lacks explicit naming, default to: `idx_<schema>_<table>`
|
||||
- Should be replaced with explicit names following standards
|
||||
- Examples (to be renamed):
|
||||
- `idx_public_users` → should be `idx_users_<column>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Code Generation
|
||||
|
||||
- Names are always lowercase in generated SQL
|
||||
- Underscore separators are required
|
||||
|
||||
### Migration Safety
|
||||
|
||||
- Do NOT rename objects after creation without explicit migration
|
||||
- Names should be consistent across all schema versions
|
||||
- Test generated DDL against PostgreSQL before deployment
|
||||
|
||||
### Testing
|
||||
|
||||
- Ensure consistency across all table and constraint generation
|
||||
- Test with reserved words to verify escaping
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- PostgreSQL Identifier Rules: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-IDENTIFIERS
|
||||
- Constraint Documentation: https://www.postgresql.org/docs/current/ddl-constraints.html
|
||||
- Index Documentation: https://www.postgresql.org/docs/current/indexes.html
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
@@ -335,7 +336,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
SchemaName: schema.Name,
|
||||
TableName: modelTable.Name,
|
||||
ColumnName: modelCol.Name,
|
||||
ColumnType: modelCol.Type,
|
||||
ColumnType: pgsql.ConvertSQLType(modelCol.Type),
|
||||
Default: defaultVal,
|
||||
NotNull: modelCol.NotNull,
|
||||
})
|
||||
@@ -359,7 +360,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
SchemaName: schema.Name,
|
||||
TableName: modelTable.Name,
|
||||
ColumnName: modelCol.Name,
|
||||
NewType: modelCol.Type,
|
||||
NewType: pgsql.ConvertSQLType(modelCol.Type),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -427,9 +428,11 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
|
||||
// Process primary keys first
|
||||
// Process primary keys first - check explicit constraints
|
||||
foundExplicitPK := false
|
||||
for constraintName, constraint := range modelTable.Constraints {
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
foundExplicitPK = true
|
||||
shouldCreate := true
|
||||
|
||||
if currentTable != nil {
|
||||
@@ -464,6 +467,53 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit PK constraint, check for columns with IsPrimaryKey = true
|
||||
if !foundExplicitPK {
|
||||
pkColumns := []string{}
|
||||
for _, col := range modelTable.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkColumns = append(pkColumns, col.SQLName())
|
||||
}
|
||||
}
|
||||
if len(pkColumns) > 0 {
|
||||
sort.Strings(pkColumns)
|
||||
constraintName := fmt.Sprintf("pk_%s_%s", model.SQLName(), modelTable.SQLName())
|
||||
shouldCreate := true
|
||||
|
||||
if currentTable != nil {
|
||||
// Check if a PK constraint already exists (by any name)
|
||||
for _, constraint := range currentTable.Constraints {
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
shouldCreate = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldCreate {
|
||||
sql, err := w.executor.ExecuteCreatePrimaryKey(CreatePrimaryKeyData{
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ConstraintName: constraintName,
|
||||
Columns: strings.Join(pkColumns, ", "),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
script := MigrationScript{
|
||||
ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName),
|
||||
ObjectType: "create primary key",
|
||||
Schema: model.Name,
|
||||
Priority: 160,
|
||||
Sequence: len(scripts),
|
||||
Body: sql,
|
||||
}
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process indexes
|
||||
for indexName, modelIndex := range modelTable.Indexes {
|
||||
// Skip primary key indexes
|
||||
@@ -703,7 +753,7 @@ func (w *MigrationWriter) generateAuditScripts(schema *models.Schema, auditConfi
|
||||
}
|
||||
|
||||
// Generate audit function
|
||||
funcName := fmt.Sprintf("ft_audit_%s", table.Name)
|
||||
funcName := fmt.Sprintf("tf_audit_%s", table.Name)
|
||||
funcData := BuildAuditFunctionData(schema.Name, table, pk, config, auditSchema, auditConfig.UserFunction)
|
||||
|
||||
funcSQL, err := w.executor.ExecuteAuditFunction(funcData)
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestWriteMigration_WithAudit(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify audit function
|
||||
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION public.ft_audit_users()") {
|
||||
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
|
||||
t.Error("Migration missing audit function")
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestTemplateExecutor_AuditFunction(t *testing.T) {
|
||||
|
||||
data := AuditFunctionData{
|
||||
SchemaName: "public",
|
||||
FunctionName: "ft_audit_users",
|
||||
FunctionName: "tf_audit_users",
|
||||
TableName: "users",
|
||||
TablePrefix: "NULL",
|
||||
PrimaryKey: "id",
|
||||
@@ -202,7 +202,7 @@ func TestTemplateExecutor_AuditFunction(t *testing.T) {
|
||||
|
||||
t.Logf("Generated SQL:\n%s", sql)
|
||||
|
||||
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION public.ft_audit_users()") {
|
||||
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
|
||||
t.Error("SQL missing function definition")
|
||||
}
|
||||
if !strings.Contains(sql, "IF TG_OP = 'INSERT'") {
|
||||
|
||||
@@ -355,7 +355,7 @@ func BuildAuditFunctionData(
|
||||
auditSchema string,
|
||||
userFunction string,
|
||||
) AuditFunctionData {
|
||||
funcName := fmt.Sprintf("ft_audit_%s", table.Name)
|
||||
funcName := fmt.Sprintf("tf_audit_%s", table.Name)
|
||||
|
||||
// Build list of audited columns
|
||||
auditedColumns := make([]*models.Column, 0)
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// Writer implements the Writer interface for PostgreSQL SQL output
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
writer io.Writer
|
||||
options *writers.WriterOptions
|
||||
writer io.Writer
|
||||
executionReport *ExecutionReport
|
||||
}
|
||||
|
||||
// ExecutionReport tracks the execution status of SQL statements
|
||||
type ExecutionReport struct {
|
||||
TotalStatements int `json:"total_statements"`
|
||||
ExecutedStatements int `json:"executed_statements"`
|
||||
FailedStatements int `json:"failed_statements"`
|
||||
Schemas []SchemaReport `json:"schemas"`
|
||||
Errors []ExecutionError `json:"errors,omitempty"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
}
|
||||
|
||||
// SchemaReport tracks execution per schema
|
||||
type SchemaReport struct {
|
||||
Name string `json:"name"`
|
||||
Tables []TableReport `json:"tables"`
|
||||
}
|
||||
|
||||
// TableReport tracks execution per table
|
||||
type TableReport struct {
|
||||
Name string `json:"name"`
|
||||
Created bool `json:"created"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionError represents a failed statement
|
||||
type ExecutionError struct {
|
||||
StatementNumber int `json:"statement_number"`
|
||||
Statement string `json:"statement"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// NewWriter creates a new PostgreSQL SQL writer
|
||||
@@ -26,6 +64,11 @@ func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
|
||||
// WriteDatabase writes the entire database schema as SQL
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Check if we should execute SQL directly on a database
|
||||
if connString, ok := w.options.Metadata["connection_string"].(string); ok && connString != "" {
|
||||
return w.executeDatabaseSQL(db, connString)
|
||||
}
|
||||
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
var err error
|
||||
@@ -125,14 +168,83 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
statements = append(statements, stmts...)
|
||||
}
|
||||
|
||||
// Phase 3.5: Add missing columns (for existing tables)
|
||||
addColStmts, err := w.GenerateAddColumnStatements(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate add column statements: %w", err)
|
||||
}
|
||||
statements = append(statements, addColStmts...)
|
||||
|
||||
// Phase 4: Primary keys
|
||||
for _, table := range schema.Tables {
|
||||
// First check for explicit PrimaryKeyConstraint
|
||||
var pkConstraint *models.Constraint
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type != models.PrimaryKeyConstraint {
|
||||
continue
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
pkConstraint = constraint
|
||||
break
|
||||
}
|
||||
stmt := fmt.Sprintf("ALTER TABLE %s.%s ADD CONSTRAINT %s PRIMARY KEY (%s)",
|
||||
schema.SQLName(), table.SQLName(), constraint.Name, strings.Join(constraint.Columns, ", "))
|
||||
}
|
||||
|
||||
var pkColumns []string
|
||||
var pkName string
|
||||
|
||||
if pkConstraint != nil {
|
||||
pkColumns = pkConstraint.Columns
|
||||
pkName = pkConstraint.Name
|
||||
} else {
|
||||
// No explicit constraint, check for columns with IsPrimaryKey = true
|
||||
pkCols := []string{}
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkCols = append(pkCols, col.SQLName())
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
// Sort for consistent output
|
||||
sort.Strings(pkCols)
|
||||
pkColumns = pkCols
|
||||
pkName = fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkColumns) > 0 {
|
||||
// Auto-generated primary key names to check for and drop
|
||||
autoGenPKNames := []string{
|
||||
fmt.Sprintf("%s_pkey", table.Name),
|
||||
fmt.Sprintf("%s_%s_pkey", schema.Name, table.Name),
|
||||
}
|
||||
|
||||
// Wrap in DO block to drop auto-generated PK and add our named PK
|
||||
stmt := fmt.Sprintf("DO $$\nDECLARE\n"+
|
||||
" auto_pk_name text;\n"+
|
||||
"BEGIN\n"+
|
||||
" -- Drop auto-generated primary key if it exists\n"+
|
||||
" SELECT constraint_name INTO auto_pk_name\n"+
|
||||
" FROM information_schema.table_constraints\n"+
|
||||
" WHERE table_schema = '%s'\n"+
|
||||
" AND table_name = '%s'\n"+
|
||||
" AND constraint_type = 'PRIMARY KEY'\n"+
|
||||
" AND constraint_name IN (%s);\n"+
|
||||
"\n"+
|
||||
" IF auto_pk_name IS NOT NULL THEN\n"+
|
||||
" EXECUTE 'ALTER TABLE %s.%s DROP CONSTRAINT ' || quote_ident(auto_pk_name);\n"+
|
||||
" END IF;\n"+
|
||||
"\n"+
|
||||
" -- Add named primary key if it doesn't exist\n"+
|
||||
" IF NOT EXISTS (\n"+
|
||||
" SELECT 1 FROM information_schema.table_constraints\n"+
|
||||
" WHERE table_schema = '%s'\n"+
|
||||
" AND table_name = '%s'\n"+
|
||||
" AND constraint_name = '%s'\n"+
|
||||
" ) THEN\n"+
|
||||
" ALTER TABLE %s.%s ADD CONSTRAINT %s PRIMARY KEY (%s);\n"+
|
||||
" END IF;\n"+
|
||||
"END;\n$$",
|
||||
schema.Name, table.Name, formatStringList(autoGenPKNames),
|
||||
schema.SQLName(), table.SQLName(),
|
||||
schema.Name, table.Name, pkName,
|
||||
schema.SQLName(), table.SQLName(), pkName, strings.Join(pkColumns, ", "))
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -155,13 +267,30 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
indexType = "btree"
|
||||
}
|
||||
|
||||
// Build column expressions with operator class support for GIN indexes
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
colExpr := colName
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
// For GIN indexes on text columns, add operator class
|
||||
if strings.EqualFold(indexType, "gin") && isTextType(col.Type) {
|
||||
opClass := extractOperatorClass(index.Comment)
|
||||
if opClass == "" {
|
||||
opClass = "gin_trgm_ops"
|
||||
}
|
||||
colExpr = fmt.Sprintf("%s %s", colName, opClass)
|
||||
}
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if index.Where != "" {
|
||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s.%s USING %s (%s)%s",
|
||||
uniqueStr, index.Name, schema.SQLName(), table.SQLName(), indexType, strings.Join(index.Columns, ", "), whereClause)
|
||||
uniqueStr, index.Name, schema.SQLName(), table.SQLName(), indexType, strings.Join(columnExprs, ", "), whereClause)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +317,18 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
onUpdate = "NO ACTION"
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("ALTER TABLE %s.%s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s.%s(%s) ON DELETE %s ON UPDATE %s",
|
||||
// Wrap in DO block to check for existing constraint
|
||||
stmt := fmt.Sprintf("DO $$\nBEGIN\n"+
|
||||
" IF NOT EXISTS (\n"+
|
||||
" SELECT 1 FROM information_schema.table_constraints\n"+
|
||||
" WHERE table_schema = '%s'\n"+
|
||||
" AND table_name = '%s'\n"+
|
||||
" AND constraint_name = '%s'\n"+
|
||||
" ) THEN\n"+
|
||||
" ALTER TABLE %s.%s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s.%s(%s) ON DELETE %s ON UPDATE %s;\n"+
|
||||
" END IF;\n"+
|
||||
"END;\n$$",
|
||||
schema.Name, table.Name, constraint.Name,
|
||||
schema.SQLName(), table.SQLName(), constraint.Name,
|
||||
strings.Join(constraint.Columns, ", "),
|
||||
strings.ToLower(refSchema), strings.ToLower(constraint.ReferencedTable),
|
||||
@@ -218,6 +358,68 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
// GenerateAddColumnStatements generates ALTER TABLE ADD COLUMN statements for existing tables
|
||||
// This is useful for schema evolution when new columns are added to existing tables
|
||||
func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, error) {
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Add missing columns for schema: %s", schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort columns by sequence or name for consistent output
|
||||
columns := make([]*models.Column, 0, len(table.Columns))
|
||||
for _, col := range table.Columns {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
sort.Slice(columns, func(i, j int) bool {
|
||||
if columns[i].Sequence != columns[j].Sequence {
|
||||
return columns[i].Sequence < columns[j].Sequence
|
||||
}
|
||||
return columns[i].Name < columns[j].Name
|
||||
})
|
||||
|
||||
for _, col := range columns {
|
||||
colDef := w.generateColumnDefinition(col)
|
||||
|
||||
// Generate DO block that checks if column exists before adding
|
||||
stmt := fmt.Sprintf("DO $$\nBEGIN\n"+
|
||||
" IF NOT EXISTS (\n"+
|
||||
" SELECT 1 FROM information_schema.columns\n"+
|
||||
" WHERE table_schema = '%s'\n"+
|
||||
" AND table_name = '%s'\n"+
|
||||
" AND column_name = '%s'\n"+
|
||||
" ) THEN\n"+
|
||||
" ALTER TABLE %s.%s ADD COLUMN %s;\n"+
|
||||
" END IF;\n"+
|
||||
"END;\n$$",
|
||||
schema.Name, table.Name, col.Name,
|
||||
schema.SQLName(), table.SQLName(), colDef)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
|
||||
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, "-- Add missing columns to existing tables")
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
|
||||
statements = append(statements, "-- Generated by RelSpec")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
schemaStatements, err := w.GenerateAddColumnStatements(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate add column statements for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
statements = append(statements, schemaStatements...)
|
||||
}
|
||||
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
// generateCreateTableStatement generates CREATE TABLE statement
|
||||
func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *models.Table) ([]string, error) {
|
||||
statements := []string{}
|
||||
@@ -240,7 +442,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
columnDefs = append(columnDefs, " "+def)
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE TABLE %s.%s (\n%s\n)",
|
||||
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.%s (\n%s\n)",
|
||||
schema.SQLName(), table.SQLName(), strings.Join(columnDefs, ",\n"))
|
||||
statements = append(statements, stmt)
|
||||
|
||||
@@ -251,16 +453,28 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
parts := []string{col.SQLName()}
|
||||
|
||||
// Type with length/precision
|
||||
typeStr := col.Type
|
||||
// Type with length/precision - convert to valid PostgreSQL type
|
||||
baseType := pgsql.ConvertSQLType(col.Type)
|
||||
typeStr := baseType
|
||||
|
||||
// Only add size specifiers for types that support them
|
||||
if col.Length > 0 && col.Precision == 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d)", col.Type, col.Length)
|
||||
} else if col.Precision > 0 {
|
||||
if col.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", col.Type, col.Precision, col.Scale)
|
||||
} else {
|
||||
typeStr = fmt.Sprintf("%s(%d)", col.Type, col.Precision)
|
||||
if supportsLength(baseType) {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Length)
|
||||
} else if isTextTypeWithoutLength(baseType) {
|
||||
// Convert text with length to varchar
|
||||
typeStr = fmt.Sprintf("varchar(%d)", col.Length)
|
||||
}
|
||||
// For types that don't support length (integer, bigint, etc.), ignore the length
|
||||
} else if col.Precision > 0 {
|
||||
if supportsPrecision(baseType) {
|
||||
if col.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", baseType, col.Precision, col.Scale)
|
||||
} else {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Precision)
|
||||
}
|
||||
}
|
||||
// For types that don't support precision, ignore it
|
||||
}
|
||||
parts = append(parts, typeStr)
|
||||
|
||||
@@ -273,12 +487,14 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
if col.Default != nil {
|
||||
switch v := col.Default.(type) {
|
||||
case string:
|
||||
if strings.HasPrefix(v, "nextval") || strings.HasPrefix(v, "CURRENT_") || strings.Contains(v, "()") {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", v))
|
||||
} else if v == "true" || v == "false" {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", v))
|
||||
// Strip backticks - DBML uses them for SQL expressions but PostgreSQL doesn't
|
||||
cleanDefault := stripBackticks(v)
|
||||
if strings.HasPrefix(cleanDefault, "nextval") || strings.HasPrefix(cleanDefault, "CURRENT_") || strings.Contains(cleanDefault, "()") {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
|
||||
} else if cleanDefault == "true" || cleanDefault == "false" {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(v)))
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(cleanDefault)))
|
||||
}
|
||||
case bool:
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
|
||||
@@ -311,6 +527,11 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 3.5: Add missing columns (priority 120)
|
||||
if err := w.writeAddColumns(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 4: Create primary keys (priority 160)
|
||||
if err := w.writePrimaryKeys(schema); err != nil {
|
||||
return err
|
||||
@@ -352,6 +573,44 @@ func (w *Writer) WriteTable(table *models.Table) error {
|
||||
return w.WriteSchema(schema)
|
||||
}
|
||||
|
||||
// WriteAddColumnStatements writes ALTER TABLE ADD COLUMN statements for a database
|
||||
// This is used for schema evolution/migration when new columns are added
|
||||
func (w *Writer) WriteAddColumnStatements(db *models.Database) error {
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
var err error
|
||||
|
||||
// Use existing writer if already set (for testing)
|
||||
if w.writer != nil {
|
||||
writer = w.writer
|
||||
} else if w.options.OutputPath != "" {
|
||||
// Determine output destination
|
||||
file, err = os.Create(w.options.OutputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer = file
|
||||
} else {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
w.writer = writer
|
||||
|
||||
// Generate statements
|
||||
statements, err := w.GenerateAddColumnsForDatabase(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write each statement
|
||||
for _, stmt := range statements {
|
||||
fmt.Fprintf(w.writer, "%s;\n\n", stmt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeCreateSchema generates CREATE SCHEMA statement
|
||||
func (w *Writer) writeCreateSchema(schema *models.Schema) error {
|
||||
if schema.Name == "public" {
|
||||
@@ -405,13 +664,8 @@ func (w *Writer) writeCreateTables(schema *models.Schema) error {
|
||||
columnDefs := make([]string, 0, len(columns))
|
||||
|
||||
for _, col := range columns {
|
||||
colDef := fmt.Sprintf(" %s %s", col.SQLName(), col.Type)
|
||||
|
||||
// Add default value if present
|
||||
if col.Default != "" {
|
||||
colDef += fmt.Sprintf(" DEFAULT %s", col.Default)
|
||||
}
|
||||
|
||||
// Use generateColumnDefinition to properly handle type, length, precision, and defaults
|
||||
colDef := " " + w.generateColumnDefinition(col)
|
||||
columnDefs = append(columnDefs, colDef)
|
||||
}
|
||||
|
||||
@@ -422,6 +676,35 @@ func (w *Writer) writeCreateTables(schema *models.Schema) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAddColumns generates ALTER TABLE ADD COLUMN statements for missing columns
|
||||
func (w *Writer) writeAddColumns(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Add missing columns for schema: %s\n", schema.Name)
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort columns by sequence or name for consistent output
|
||||
columns := getSortedColumns(table.Columns)
|
||||
|
||||
for _, col := range columns {
|
||||
colDef := w.generateColumnDefinition(col)
|
||||
|
||||
// Generate DO block that checks if column exists before adding
|
||||
fmt.Fprintf(w.writer, "DO $$\nBEGIN\n")
|
||||
fmt.Fprintf(w.writer, " IF NOT EXISTS (\n")
|
||||
fmt.Fprintf(w.writer, " SELECT 1 FROM information_schema.columns\n")
|
||||
fmt.Fprintf(w.writer, " WHERE table_schema = '%s'\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, " AND table_name = '%s'\n", table.Name)
|
||||
fmt.Fprintf(w.writer, " AND column_name = '%s'\n", col.Name)
|
||||
fmt.Fprintf(w.writer, " ) THEN\n")
|
||||
fmt.Fprintf(w.writer, " ALTER TABLE %s.%s ADD COLUMN %s;\n",
|
||||
schema.SQLName(), table.SQLName(), colDef)
|
||||
fmt.Fprintf(w.writer, " END IF;\n")
|
||||
fmt.Fprintf(w.writer, "END;\n$$;\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writePrimaryKeys generates ALTER TABLE statements for primary keys
|
||||
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
|
||||
@@ -437,26 +720,58 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
}
|
||||
}
|
||||
|
||||
if pkConstraint == nil {
|
||||
// No explicit PK constraint, skip
|
||||
continue
|
||||
}
|
||||
|
||||
var columnNames []string
|
||||
pkName := fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
|
||||
|
||||
// Build column list
|
||||
columnNames := make([]string, 0, len(pkConstraint.Columns))
|
||||
for _, colName := range pkConstraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
if pkConstraint != nil {
|
||||
// Build column list from explicit constraint
|
||||
columnNames = make([]string, 0, len(pkConstraint.Columns))
|
||||
for _, colName := range pkConstraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No explicit PK constraint, check for columns with IsPrimaryKey = true
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
}
|
||||
}
|
||||
// Sort for consistent output
|
||||
sort.Strings(columnNames)
|
||||
}
|
||||
|
||||
if len(columnNames) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "DO $$\nBEGIN\n")
|
||||
// Auto-generated primary key names to check for and drop
|
||||
autoGenPKNames := []string{
|
||||
fmt.Sprintf("%s_pkey", table.Name),
|
||||
fmt.Sprintf("%s_%s_pkey", schema.Name, table.Name),
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "DO $$\nDECLARE\n")
|
||||
fmt.Fprintf(w.writer, " auto_pk_name text;\nBEGIN\n")
|
||||
|
||||
// Check for and drop auto-generated primary keys
|
||||
fmt.Fprintf(w.writer, " -- Drop auto-generated primary key if it exists\n")
|
||||
fmt.Fprintf(w.writer, " SELECT constraint_name INTO auto_pk_name\n")
|
||||
fmt.Fprintf(w.writer, " FROM information_schema.table_constraints\n")
|
||||
fmt.Fprintf(w.writer, " WHERE table_schema = '%s'\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, " AND table_name = '%s'\n", table.Name)
|
||||
fmt.Fprintf(w.writer, " AND constraint_type = 'PRIMARY KEY'\n")
|
||||
fmt.Fprintf(w.writer, " AND constraint_name IN (%s);\n", formatStringList(autoGenPKNames))
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
fmt.Fprintf(w.writer, " IF auto_pk_name IS NOT NULL THEN\n")
|
||||
fmt.Fprintf(w.writer, " EXECUTE 'ALTER TABLE %s.%s DROP CONSTRAINT ' || quote_ident(auto_pk_name);\n",
|
||||
schema.SQLName(), table.SQLName())
|
||||
fmt.Fprintf(w.writer, " END IF;\n")
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
|
||||
// Add our named primary key if it doesn't exist
|
||||
fmt.Fprintf(w.writer, " -- Add named primary key if it doesn't exist\n")
|
||||
fmt.Fprintf(w.writer, " IF NOT EXISTS (\n")
|
||||
fmt.Fprintf(w.writer, " SELECT 1 FROM information_schema.table_constraints\n")
|
||||
fmt.Fprintf(w.writer, " WHERE table_schema = '%s'\n", schema.Name)
|
||||
@@ -498,20 +813,30 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
if indexName == "" {
|
||||
indexType := "idx"
|
||||
if index.Unique {
|
||||
indexType = "uk"
|
||||
indexType = "uidx"
|
||||
}
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, schema.SQLName(), table.SQLName())
|
||||
columnSuffix := strings.Join(index.Columns, "_")
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix))
|
||||
}
|
||||
|
||||
// Build column list
|
||||
columnNames := make([]string, 0, len(index.Columns))
|
||||
// Build column list with operator class support for GIN indexes
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
colExpr := col.SQLName()
|
||||
// For GIN indexes on text columns, add operator class
|
||||
if strings.EqualFold(index.Type, "gin") && isTextType(col.Type) {
|
||||
opClass := extractOperatorClass(index.Comment)
|
||||
if opClass == "" {
|
||||
opClass = "gin_trgm_ops"
|
||||
}
|
||||
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
}
|
||||
}
|
||||
|
||||
if len(columnNames) == 0 {
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -520,10 +845,20 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
|
||||
indexType := index.Type
|
||||
if indexType == "" {
|
||||
indexType = "btree"
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if index.Where != "" {
|
||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "CREATE %sINDEX IF NOT EXISTS %s\n",
|
||||
unique, indexName)
|
||||
fmt.Fprintf(w.writer, " ON %s.%s USING btree (%s);\n\n",
|
||||
schema.SQLName(), table.SQLName(), strings.Join(columnNames, ", "))
|
||||
fmt.Fprintf(w.writer, " ON %s.%s USING %s (%s)%s;\n\n",
|
||||
schema.SQLName(), table.SQLName(), indexType, strings.Join(columnExprs, ", "), whereClause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,13 +932,6 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
onUpdate = strings.ToUpper(fkConstraint.OnUpdate)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s.%s\n", schema.SQLName(), table.SQLName())
|
||||
fmt.Fprintf(w.writer, " DROP CONSTRAINT IF EXISTS %s;\n", fkName)
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s.%s\n", schema.SQLName(), table.SQLName())
|
||||
fmt.Fprintf(w.writer, " ADD CONSTRAINT %s\n", fkName)
|
||||
fmt.Fprintf(w.writer, " FOREIGN KEY (%s)\n", strings.Join(sourceColumns, ", "))
|
||||
|
||||
// Use constraint's referenced schema/table or relationship's ToSchema/ToTable
|
||||
refSchema := fkConstraint.ReferencedSchema
|
||||
if refSchema == "" {
|
||||
@@ -614,11 +942,24 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
refTable = rel.ToTable
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, " REFERENCES %s.%s (%s)\n",
|
||||
// Use DO block to check if constraint exists before adding
|
||||
fmt.Fprintf(w.writer, "DO $$\nBEGIN\n")
|
||||
fmt.Fprintf(w.writer, " IF NOT EXISTS (\n")
|
||||
fmt.Fprintf(w.writer, " SELECT 1 FROM information_schema.table_constraints\n")
|
||||
fmt.Fprintf(w.writer, " WHERE table_schema = '%s'\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, " AND table_name = '%s'\n", table.Name)
|
||||
fmt.Fprintf(w.writer, " AND constraint_name = '%s'\n", fkName)
|
||||
fmt.Fprintf(w.writer, " ) THEN\n")
|
||||
fmt.Fprintf(w.writer, " ALTER TABLE %s.%s\n", schema.SQLName(), table.SQLName())
|
||||
fmt.Fprintf(w.writer, " ADD CONSTRAINT %s\n", fkName)
|
||||
fmt.Fprintf(w.writer, " FOREIGN KEY (%s)\n", strings.Join(sourceColumns, ", "))
|
||||
fmt.Fprintf(w.writer, " REFERENCES %s.%s (%s)\n",
|
||||
refSchema, refTable, strings.Join(targetColumns, ", "))
|
||||
fmt.Fprintf(w.writer, " ON DELETE %s\n", onDelete)
|
||||
fmt.Fprintf(w.writer, " ON UPDATE %s\n", onUpdate)
|
||||
fmt.Fprintf(w.writer, " DEFERRABLE;\n\n")
|
||||
fmt.Fprintf(w.writer, " ON DELETE %s\n", onDelete)
|
||||
fmt.Fprintf(w.writer, " ON UPDATE %s\n", onUpdate)
|
||||
fmt.Fprintf(w.writer, " DEFERRABLE;\n")
|
||||
fmt.Fprintf(w.writer, " END IF;\n")
|
||||
fmt.Fprintf(w.writer, "END;\n$$;\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,11 +1059,84 @@ func isIntegerType(colType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isTextType checks if a column type is a text type (for GIN index operator class)
|
||||
func isTextType(colType string) bool {
|
||||
textTypes := []string{"text", "varchar", "character varying", "char", "character", "string"}
|
||||
lowerType := strings.ToLower(colType)
|
||||
for _, t := range textTypes {
|
||||
if strings.HasPrefix(lowerType, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// supportsLength checks if a PostgreSQL type supports length specification
|
||||
func supportsLength(colType string) bool {
|
||||
lengthTypes := []string{"varchar", "character varying", "char", "character", "bit", "bit varying", "varbit"}
|
||||
lowerType := strings.ToLower(colType)
|
||||
for _, t := range lengthTypes {
|
||||
if lowerType == t || strings.HasPrefix(lowerType, t+"(") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// supportsPrecision checks if a PostgreSQL type supports precision/scale specification
|
||||
func supportsPrecision(colType string) bool {
|
||||
precisionTypes := []string{"numeric", "decimal", "time", "timestamp", "timestamptz", "timestamp with time zone", "timestamp without time zone", "time with time zone", "time without time zone", "interval"}
|
||||
lowerType := strings.ToLower(colType)
|
||||
for _, t := range precisionTypes {
|
||||
if lowerType == t || strings.HasPrefix(lowerType, t+"(") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTextTypeWithoutLength checks if type is text (which should convert to varchar when length is specified)
|
||||
func isTextTypeWithoutLength(colType string) bool {
|
||||
return strings.EqualFold(colType, "text")
|
||||
}
|
||||
|
||||
// formatStringList formats a list of strings as a SQL-safe comma-separated quoted list
|
||||
func formatStringList(items []string) string {
|
||||
quoted := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
quoted[i] = fmt.Sprintf("'%s'", escapeQuote(item))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// extractOperatorClass extracts operator class from index comment/note
|
||||
// Looks for common operator classes like gin_trgm_ops, gist_trgm_ops, etc.
|
||||
func extractOperatorClass(comment string) string {
|
||||
if comment == "" {
|
||||
return ""
|
||||
}
|
||||
lowerComment := strings.ToLower(comment)
|
||||
// Common GIN/GiST operator classes
|
||||
opClasses := []string{"gin_trgm_ops", "gist_trgm_ops", "gin_bigm_ops", "jsonb_ops", "jsonb_path_ops", "array_ops"}
|
||||
for _, op := range opClasses {
|
||||
if strings.Contains(lowerComment, op) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// escapeQuote escapes single quotes in strings for SQL
|
||||
func escapeQuote(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
// stripBackticks removes backticks from SQL expressions
|
||||
// DBML uses backticks for SQL expressions like `now()`, but PostgreSQL doesn't use backticks
|
||||
func stripBackticks(s string) string {
|
||||
return strings.ReplaceAll(s, "`", "")
|
||||
}
|
||||
|
||||
// extractSequenceName extracts sequence name from nextval() expression
|
||||
// Example: "nextval('public.users_id_seq'::regclass)" returns "users_id_seq"
|
||||
func extractSequenceName(defaultExpr string) string {
|
||||
@@ -745,3 +1159,195 @@ func extractSequenceName(defaultExpr string) string {
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
|
||||
// executeDatabaseSQL executes SQL statements directly on a PostgreSQL database
|
||||
func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) error {
|
||||
// Initialize execution report
|
||||
w.executionReport = &ExecutionReport{
|
||||
StartTime: getCurrentTimestamp(),
|
||||
Schemas: make([]SchemaReport, 0),
|
||||
Errors: make([]ExecutionError, 0),
|
||||
}
|
||||
|
||||
// Generate SQL statements
|
||||
statements, err := w.GenerateDatabaseStatements(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate SQL statements: %w", err)
|
||||
}
|
||||
|
||||
w.executionReport.TotalStatements = len(statements)
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
// Track schemas and tables
|
||||
schemaMap := make(map[string]*SchemaReport)
|
||||
currentSchema := ""
|
||||
|
||||
// Execute each statement
|
||||
for i, stmt := range statements {
|
||||
stmtTrimmed := strings.TrimSpace(stmt)
|
||||
|
||||
// Skip comments
|
||||
if strings.HasPrefix(stmtTrimmed, "--") {
|
||||
// Check if this is a schema comment to track schema changes
|
||||
if strings.Contains(stmtTrimmed, "Schema:") {
|
||||
parts := strings.Split(stmtTrimmed, "Schema:")
|
||||
if len(parts) > 1 {
|
||||
currentSchema = strings.TrimSpace(parts[1])
|
||||
if _, exists := schemaMap[currentSchema]; !exists {
|
||||
schemaReport := SchemaReport{
|
||||
Name: currentSchema,
|
||||
Tables: make([]TableReport, 0),
|
||||
}
|
||||
schemaMap[currentSchema] = &schemaReport
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty statements
|
||||
if stmtTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d...\n", i+1, len(statements))
|
||||
|
||||
_, execErr := conn.Exec(ctx, stmt)
|
||||
if execErr != nil {
|
||||
w.executionReport.FailedStatements++
|
||||
execError := ExecutionError{
|
||||
StatementNumber: i + 1,
|
||||
Statement: truncateStatement(stmt),
|
||||
Error: execErr.Error(),
|
||||
}
|
||||
w.executionReport.Errors = append(w.executionReport.Errors, execError)
|
||||
|
||||
// Track table creation failure
|
||||
if strings.Contains(strings.ToUpper(stmtTrimmed), "CREATE TABLE") && currentSchema != "" {
|
||||
tableName := extractTableNameFromCreate(stmtTrimmed)
|
||||
if tableName != "" && schemaMap[currentSchema] != nil {
|
||||
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, TableReport{
|
||||
Name: tableName,
|
||||
Created: false,
|
||||
Error: execErr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with next statement instead of failing completely
|
||||
fmt.Fprintf(os.Stderr, "⚠ Warning: Statement %d failed: %v\n", i+1, execErr)
|
||||
continue
|
||||
}
|
||||
|
||||
w.executionReport.ExecutedStatements++
|
||||
|
||||
// Track successful table creation
|
||||
if strings.Contains(strings.ToUpper(stmtTrimmed), "CREATE TABLE") && currentSchema != "" {
|
||||
tableName := extractTableNameFromCreate(stmtTrimmed)
|
||||
if tableName != "" && schemaMap[currentSchema] != nil {
|
||||
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, TableReport{
|
||||
Name: tableName,
|
||||
Created: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert schema map to slice
|
||||
for _, schemaReport := range schemaMap {
|
||||
w.executionReport.Schemas = append(w.executionReport.Schemas, *schemaReport)
|
||||
}
|
||||
|
||||
w.executionReport.EndTime = getCurrentTimestamp()
|
||||
|
||||
// Write report if path is specified
|
||||
if reportPath, ok := w.options.Metadata["report_path"].(string); ok && reportPath != "" {
|
||||
if err := w.writeReport(reportPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Warning: Failed to write report: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✓ Report written to: %s\n", reportPath)
|
||||
}
|
||||
}
|
||||
|
||||
if w.executionReport.FailedStatements > 0 {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Completed with %d errors out of %d statements\n",
|
||||
w.executionReport.FailedStatements, w.executionReport.TotalStatements)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully executed %d statements\n", w.executionReport.ExecutedStatements)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeReport writes the execution report to a JSON file
|
||||
func (w *Writer) writeReport(reportPath string) error {
|
||||
file, err := os.Create(reportPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create report file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(w.executionReport); err != nil {
|
||||
return fmt.Errorf("failed to encode report: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTableNameFromCreate extracts table name from CREATE TABLE statement
|
||||
func extractTableNameFromCreate(stmt string) string {
|
||||
// Match: CREATE TABLE [IF NOT EXISTS] schema.table_name or table_name
|
||||
upper := strings.ToUpper(stmt)
|
||||
idx := strings.Index(upper, "CREATE TABLE")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(stmt[idx+12:]) // Skip "CREATE TABLE"
|
||||
|
||||
// Skip "IF NOT EXISTS"
|
||||
if strings.HasPrefix(strings.ToUpper(rest), "IF NOT EXISTS") {
|
||||
rest = strings.TrimSpace(rest[13:])
|
||||
}
|
||||
|
||||
// Get the table name (first token before '(' or whitespace)
|
||||
tokens := strings.FieldsFunc(rest, func(r rune) bool {
|
||||
return r == '(' || r == ' ' || r == '\n' || r == '\t'
|
||||
})
|
||||
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle schema.table format
|
||||
fullName := tokens[0]
|
||||
parts := strings.Split(fullName, ".")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return fullName
|
||||
}
|
||||
|
||||
// truncateStatement truncates long SQL statements for error messages
|
||||
func truncateStatement(stmt string) string {
|
||||
const maxLen = 200
|
||||
if len(stmt) <= maxLen {
|
||||
return stmt
|
||||
}
|
||||
return stmt[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// getCurrentTimestamp returns the current timestamp in a readable format
|
||||
func getCurrentTimestamp() string {
|
||||
return time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ func TestWriteDatabase(t *testing.T) {
|
||||
|
||||
// Add unique index
|
||||
uniqueEmailIndex := &models.Index{
|
||||
Name: "uk_users_email",
|
||||
Name: "uidx_users_email",
|
||||
Unique: true,
|
||||
Columns: []string{"email"},
|
||||
}
|
||||
table.Indexes["uk_users_email"] = uniqueEmailIndex
|
||||
table.Indexes["uidx_users_email"] = uniqueEmailIndex
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
@@ -241,3 +241,327 @@ func TestIsIntegerType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeConversion(t *testing.T) {
|
||||
// Test that invalid Go types are converted to valid PostgreSQL types
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create a test table with Go types instead of SQL types
|
||||
table := models.InitTable("test_types", "public")
|
||||
|
||||
// Add columns with Go types (invalid for PostgreSQL)
|
||||
stringCol := models.InitColumn("name", "test_types", "public")
|
||||
stringCol.Type = "string" // Should be converted to "text"
|
||||
table.Columns["name"] = stringCol
|
||||
|
||||
int64Col := models.InitColumn("big_id", "test_types", "public")
|
||||
int64Col.Type = "int64" // Should be converted to "bigint"
|
||||
table.Columns["big_id"] = int64Col
|
||||
|
||||
int16Col := models.InitColumn("small_id", "test_types", "public")
|
||||
int16Col.Type = "int16" // Should be converted to "smallint"
|
||||
table.Columns["small_id"] = int16Col
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer with output to buffer
|
||||
var buf bytes.Buffer
|
||||
options := &writers.WriterOptions{}
|
||||
writer := NewWriter(options)
|
||||
writer.writer = &buf
|
||||
|
||||
// Write the database
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Print output for debugging
|
||||
t.Logf("Generated SQL:\n%s", output)
|
||||
|
||||
// Verify that Go types were converted to PostgreSQL types
|
||||
if strings.Contains(output, "string") {
|
||||
t.Errorf("Output contains 'string' type - should be converted to 'text'\nFull output:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "int64") {
|
||||
t.Errorf("Output contains 'int64' type - should be converted to 'bigint'\nFull output:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "int16") {
|
||||
t.Errorf("Output contains 'int16' type - should be converted to 'smallint'\nFull output:\n%s", output)
|
||||
}
|
||||
|
||||
// Verify correct PostgreSQL types are present
|
||||
if !strings.Contains(output, "text") {
|
||||
t.Errorf("Output missing 'text' type (converted from 'string')\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "bigint") {
|
||||
t.Errorf("Output missing 'bigint' type (converted from 'int64')\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "smallint") {
|
||||
t.Errorf("Output missing 'smallint' type (converted from 'int16')\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryKeyExistenceCheck(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
table := models.InitTable("products", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "products", "public")
|
||||
idCol.Type = "integer"
|
||||
idCol.IsPrimaryKey = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
nameCol := models.InitColumn("name", "products", "public")
|
||||
nameCol.Type = "text"
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
options := &writers.WriterOptions{}
|
||||
writer := NewWriter(options)
|
||||
writer.writer = &buf
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
t.Logf("Generated SQL:\n%s", output)
|
||||
|
||||
// Verify our naming convention is used
|
||||
if !strings.Contains(output, "pk_public_products") {
|
||||
t.Errorf("Output missing expected primary key name 'pk_public_products'\nFull output:\n%s", output)
|
||||
}
|
||||
|
||||
// Verify it drops auto-generated primary keys
|
||||
if !strings.Contains(output, "products_pkey") || !strings.Contains(output, "DROP CONSTRAINT") {
|
||||
t.Errorf("Output missing logic to drop auto-generated primary key\nFull output:\n%s", output)
|
||||
}
|
||||
|
||||
// Verify it checks for our specific named constraint before adding it
|
||||
if !strings.Contains(output, "constraint_name = 'pk_public_products'") {
|
||||
t.Errorf("Output missing check for our named primary key constraint\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnSizeSpecifiers(t *testing.T) {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
table := models.InitTable("test_sizes", "public")
|
||||
|
||||
// Integer with invalid size specifier - should ignore size
|
||||
integerCol := models.InitColumn("int_col", "test_sizes", "public")
|
||||
integerCol.Type = "integer"
|
||||
integerCol.Length = 32
|
||||
table.Columns["int_col"] = integerCol
|
||||
|
||||
// Bigint with invalid size specifier - should ignore size
|
||||
bigintCol := models.InitColumn("bigint_col", "test_sizes", "public")
|
||||
bigintCol.Type = "bigint"
|
||||
bigintCol.Length = 64
|
||||
table.Columns["bigint_col"] = bigintCol
|
||||
|
||||
// Smallint with invalid size specifier - should ignore size
|
||||
smallintCol := models.InitColumn("smallint_col", "test_sizes", "public")
|
||||
smallintCol.Type = "smallint"
|
||||
smallintCol.Length = 16
|
||||
table.Columns["smallint_col"] = smallintCol
|
||||
|
||||
// Text with length - should convert to varchar
|
||||
textCol := models.InitColumn("text_col", "test_sizes", "public")
|
||||
textCol.Type = "text"
|
||||
textCol.Length = 100
|
||||
table.Columns["text_col"] = textCol
|
||||
|
||||
// Varchar with length - should keep varchar with length
|
||||
varcharCol := models.InitColumn("varchar_col", "test_sizes", "public")
|
||||
varcharCol.Type = "varchar"
|
||||
varcharCol.Length = 50
|
||||
table.Columns["varchar_col"] = varcharCol
|
||||
|
||||
// Decimal with precision and scale - should keep them
|
||||
decimalCol := models.InitColumn("decimal_col", "test_sizes", "public")
|
||||
decimalCol.Type = "decimal"
|
||||
decimalCol.Precision = 19
|
||||
decimalCol.Scale = 4
|
||||
table.Columns["decimal_col"] = decimalCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
var buf bytes.Buffer
|
||||
options := &writers.WriterOptions{}
|
||||
writer := NewWriter(options)
|
||||
writer.writer = &buf
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
t.Logf("Generated SQL:\n%s", output)
|
||||
|
||||
// Verify invalid size specifiers are NOT present
|
||||
invalidPatterns := []string{
|
||||
"integer(32)",
|
||||
"bigint(64)",
|
||||
"smallint(16)",
|
||||
"text(100)",
|
||||
}
|
||||
for _, pattern := range invalidPatterns {
|
||||
if strings.Contains(output, pattern) {
|
||||
t.Errorf("Output contains invalid pattern '%s' - PostgreSQL doesn't support this\nFull output:\n%s", pattern, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify valid patterns ARE present
|
||||
validPatterns := []string{
|
||||
"integer", // without size
|
||||
"bigint", // without size
|
||||
"smallint", // without size
|
||||
"varchar(100)", // text converted to varchar with length
|
||||
"varchar(50)", // varchar with length
|
||||
"decimal(19,4)", // decimal with precision and scale
|
||||
}
|
||||
for _, pattern := range validPatterns {
|
||||
if !strings.Contains(output, pattern) {
|
||||
t.Errorf("Output missing expected pattern '%s'\nFull output:\n%s", pattern, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAddColumnStatements(t *testing.T) {
|
||||
// Create a test database with tables that have new columns
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create a table with columns
|
||||
table := models.InitTable("users", "public")
|
||||
|
||||
// Existing column
|
||||
idCol := models.InitColumn("id", "users", "public")
|
||||
idCol.Type = "integer"
|
||||
idCol.NotNull = true
|
||||
idCol.Sequence = 1
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
// New column to be added
|
||||
emailCol := models.InitColumn("email", "users", "public")
|
||||
emailCol.Type = "varchar"
|
||||
emailCol.Length = 255
|
||||
emailCol.NotNull = true
|
||||
emailCol.Sequence = 2
|
||||
table.Columns["email"] = emailCol
|
||||
|
||||
// New column with default
|
||||
statusCol := models.InitColumn("status", "users", "public")
|
||||
statusCol.Type = "text"
|
||||
statusCol.Default = "active"
|
||||
statusCol.Sequence = 3
|
||||
table.Columns["status"] = statusCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer
|
||||
options := &writers.WriterOptions{}
|
||||
writer := NewWriter(options)
|
||||
|
||||
// Generate ADD COLUMN statements
|
||||
statements, err := writer.GenerateAddColumnsForDatabase(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAddColumnsForDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Join all statements to verify content
|
||||
output := strings.Join(statements, "\n")
|
||||
t.Logf("Generated ADD COLUMN statements:\n%s", output)
|
||||
|
||||
// Verify expected elements
|
||||
expectedStrings := []string{
|
||||
"ALTER TABLE public.users ADD COLUMN id integer NOT NULL",
|
||||
"ALTER TABLE public.users ADD COLUMN email varchar(255) NOT NULL",
|
||||
"ALTER TABLE public.users ADD COLUMN status text DEFAULT 'active'",
|
||||
"information_schema.columns",
|
||||
"table_schema = 'public'",
|
||||
"table_name = 'users'",
|
||||
"column_name = 'id'",
|
||||
"column_name = 'email'",
|
||||
"column_name = 'status'",
|
||||
}
|
||||
|
||||
for _, expected := range expectedStrings {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("Output missing expected string: %s\nFull output:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify DO blocks are present for conditional adds
|
||||
doBlockCount := strings.Count(output, "DO $$")
|
||||
if doBlockCount < 3 {
|
||||
t.Errorf("Expected at least 3 DO blocks (one per column), got %d", doBlockCount)
|
||||
}
|
||||
|
||||
// Verify IF NOT EXISTS logic
|
||||
ifNotExistsCount := strings.Count(output, "IF NOT EXISTS")
|
||||
if ifNotExistsCount < 3 {
|
||||
t.Errorf("Expected at least 3 IF NOT EXISTS checks (one per column), got %d", ifNotExistsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAddColumnStatements(t *testing.T) {
|
||||
// Create a test database
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create a table with a new column to be added
|
||||
table := models.InitTable("products", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "products", "public")
|
||||
idCol.Type = "integer"
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
// New column with various properties
|
||||
descCol := models.InitColumn("description", "products", "public")
|
||||
descCol.Type = "text"
|
||||
descCol.NotNull = false
|
||||
table.Columns["description"] = descCol
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
// Create writer with output to buffer
|
||||
var buf bytes.Buffer
|
||||
options := &writers.WriterOptions{}
|
||||
writer := NewWriter(options)
|
||||
writer.writer = &buf
|
||||
|
||||
// Write ADD COLUMN statements
|
||||
err := writer.WriteAddColumnStatements(db)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteAddColumnStatements failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
t.Logf("Generated output:\n%s", output)
|
||||
|
||||
// Verify output contains expected elements
|
||||
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN id integer") {
|
||||
t.Errorf("Output missing ADD COLUMN for id\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN description text") {
|
||||
t.Errorf("Output missing ADD COLUMN for description\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "DO $$") {
|
||||
t.Errorf("Output missing DO block\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ The SQL Executor Writer (`sqlexec`) executes SQL scripts from `models.Script` ob
|
||||
|
||||
## Features
|
||||
|
||||
- **Ordered Execution**: Scripts execute in Priority→Sequence order
|
||||
- **Ordered Execution**: Scripts execute in Priority→Sequence→Name order
|
||||
- **PostgreSQL Support**: Uses `pgx/v5` driver for robust PostgreSQL connectivity
|
||||
- **Stop on Error**: Execution halts immediately on first error (default behavior)
|
||||
- **Progress Reporting**: Prints execution status to stdout
|
||||
@@ -103,19 +103,40 @@ Scripts are sorted and executed based on:
|
||||
|
||||
1. **Priority** (ascending): Lower priority values execute first
|
||||
2. **Sequence** (ascending): Within same priority, lower sequence values execute first
|
||||
3. **Name** (ascending): Within same priority and sequence, alphabetical order by name
|
||||
|
||||
### Example Execution Order
|
||||
|
||||
Given these scripts:
|
||||
```
|
||||
Script A: Priority=2, Sequence=1
|
||||
Script B: Priority=1, Sequence=3
|
||||
Script C: Priority=1, Sequence=1
|
||||
Script D: Priority=1, Sequence=2
|
||||
Script E: Priority=3, Sequence=1
|
||||
Script A: Priority=2, Sequence=1, Name="zebra"
|
||||
Script B: Priority=1, Sequence=3, Name="script"
|
||||
Script C: Priority=1, Sequence=1, Name="apple"
|
||||
Script D: Priority=1, Sequence=1, Name="beta"
|
||||
Script E: Priority=3, Sequence=1, Name="script"
|
||||
```
|
||||
|
||||
Execution order: **C → D → B → A → E**
|
||||
Execution order: **C (apple) → D (beta) → B → A → E**
|
||||
|
||||
### Directory-based Sorting Example
|
||||
|
||||
Given these files:
|
||||
```
|
||||
1_001_create_schema.sql
|
||||
1_001_create_users.sql ← Alphabetically before "drop_tables"
|
||||
1_001_drop_tables.sql
|
||||
1_002_add_indexes.sql
|
||||
2_001_constraints.sql
|
||||
```
|
||||
|
||||
Execution order (note alphabetical sorting at same priority/sequence):
|
||||
```
|
||||
1_001_create_schema.sql
|
||||
1_001_create_users.sql
|
||||
1_001_drop_tables.sql
|
||||
1_002_add_indexes.sql
|
||||
2_001_constraints.sql
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
}
|
||||
}
|
||||
|
||||
// Options returns the writer options (useful for reading execution results)
|
||||
func (w *Writer) Options() *writers.WriterOptions {
|
||||
return w.options
|
||||
}
|
||||
|
||||
// WriteDatabase executes all scripts from all schemas in the database
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
if db == nil {
|
||||
@@ -86,20 +91,39 @@ func (w *Writer) WriteTable(table *models.Table) error {
|
||||
return fmt.Errorf("WriteTable is not supported for SQL script execution")
|
||||
}
|
||||
|
||||
// executeScripts executes scripts in Priority then Sequence order
|
||||
// executeScripts executes scripts in Priority, Sequence, then Name order
|
||||
func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*models.Script) error {
|
||||
if len(scripts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort scripts by Priority (ascending) then Sequence (ascending)
|
||||
// Check if we should ignore errors
|
||||
ignoreErrors := false
|
||||
if val, ok := w.options.Metadata["ignore_errors"].(bool); ok {
|
||||
ignoreErrors = val
|
||||
}
|
||||
|
||||
// Track failed scripts and execution counts
|
||||
var failedScripts []struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
err error
|
||||
}
|
||||
successCount := 0
|
||||
totalCount := 0
|
||||
|
||||
// Sort scripts by Priority (ascending), Sequence (ascending), then Name (ascending)
|
||||
sortedScripts := make([]*models.Script, len(scripts))
|
||||
copy(sortedScripts, scripts)
|
||||
sort.Slice(sortedScripts, func(i, j int) bool {
|
||||
if sortedScripts[i].Priority != sortedScripts[j].Priority {
|
||||
return sortedScripts[i].Priority < sortedScripts[j].Priority
|
||||
}
|
||||
return sortedScripts[i].Sequence < sortedScripts[j].Sequence
|
||||
if sortedScripts[i].Sequence != sortedScripts[j].Sequence {
|
||||
return sortedScripts[i].Sequence < sortedScripts[j].Sequence
|
||||
}
|
||||
return sortedScripts[i].Name < sortedScripts[j].Name
|
||||
})
|
||||
|
||||
// Execute each script in order
|
||||
@@ -108,18 +132,49 @@ func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*
|
||||
continue
|
||||
}
|
||||
|
||||
totalCount++
|
||||
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n",
|
||||
script.Name, script.Priority, script.Sequence)
|
||||
|
||||
// Execute the SQL script
|
||||
_, err := conn.Exec(ctx, script.SQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute script %s (Priority=%d, Sequence=%d): %w",
|
||||
if ignoreErrors {
|
||||
fmt.Printf("⚠ Error executing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
||||
failedScripts = append(failedScripts, struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
err error
|
||||
}{
|
||||
name: script.Name,
|
||||
priority: script.Priority,
|
||||
sequence: script.Sequence,
|
||||
err: err,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
|
||||
script.Name, script.Priority, script.Sequence, err)
|
||||
}
|
||||
|
||||
successCount++
|
||||
fmt.Printf("✓ Successfully executed: %s\n", script.Name)
|
||||
}
|
||||
|
||||
// Store execution results in metadata for caller
|
||||
w.options.Metadata["execution_total"] = totalCount
|
||||
w.options.Metadata["execution_success"] = successCount
|
||||
w.options.Metadata["execution_failed"] = len(failedScripts)
|
||||
|
||||
// Print summary of failed scripts if any
|
||||
if len(failedScripts) > 0 {
|
||||
fmt.Printf("\n⚠ Failed Scripts Summary (%d failed):\n", len(failedScripts))
|
||||
for i, failed := range failedScripts {
|
||||
fmt.Printf(" %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
|
||||
i+1, failed.name, failed.priority, failed.sequence, failed.err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,13 +99,13 @@ func TestWriter_WriteTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptSorting verifies that scripts are sorted correctly by Priority then Sequence
|
||||
// TestScriptSorting verifies that scripts are sorted correctly by Priority, Sequence, then Name
|
||||
func TestScriptSorting(t *testing.T) {
|
||||
scripts := []*models.Script{
|
||||
{Name: "script1", Priority: 2, Sequence: 1, SQL: "SELECT 1;"},
|
||||
{Name: "z_script1", Priority: 2, Sequence: 1, SQL: "SELECT 1;"},
|
||||
{Name: "script2", Priority: 1, Sequence: 3, SQL: "SELECT 2;"},
|
||||
{Name: "script3", Priority: 1, Sequence: 1, SQL: "SELECT 3;"},
|
||||
{Name: "script4", Priority: 1, Sequence: 2, SQL: "SELECT 4;"},
|
||||
{Name: "a_script3", Priority: 1, Sequence: 1, SQL: "SELECT 3;"},
|
||||
{Name: "b_script4", Priority: 1, Sequence: 1, SQL: "SELECT 4;"},
|
||||
{Name: "script5", Priority: 3, Sequence: 1, SQL: "SELECT 5;"},
|
||||
{Name: "script6", Priority: 2, Sequence: 2, SQL: "SELECT 6;"},
|
||||
}
|
||||
@@ -114,25 +114,35 @@ func TestScriptSorting(t *testing.T) {
|
||||
sortedScripts := make([]*models.Script, len(scripts))
|
||||
copy(sortedScripts, scripts)
|
||||
|
||||
// Use the same sorting logic from executeScripts
|
||||
// Sort by Priority, Sequence, then Name (matching executeScripts logic)
|
||||
for i := 0; i < len(sortedScripts)-1; i++ {
|
||||
for j := i + 1; j < len(sortedScripts); j++ {
|
||||
if sortedScripts[i].Priority > sortedScripts[j].Priority ||
|
||||
(sortedScripts[i].Priority == sortedScripts[j].Priority &&
|
||||
sortedScripts[i].Sequence > sortedScripts[j].Sequence) {
|
||||
si, sj := sortedScripts[i], sortedScripts[j]
|
||||
// Compare by priority first
|
||||
if si.Priority > sj.Priority {
|
||||
sortedScripts[i], sortedScripts[j] = sortedScripts[j], sortedScripts[i]
|
||||
} else if si.Priority == sj.Priority {
|
||||
// If same priority, compare by sequence
|
||||
if si.Sequence > sj.Sequence {
|
||||
sortedScripts[i], sortedScripts[j] = sortedScripts[j], sortedScripts[i]
|
||||
} else if si.Sequence == sj.Sequence {
|
||||
// If same sequence, compare by name
|
||||
if si.Name > sj.Name {
|
||||
sortedScripts[i], sortedScripts[j] = sortedScripts[j], sortedScripts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expected order after sorting
|
||||
// Expected order after sorting (Priority -> Sequence -> Name)
|
||||
expectedOrder := []string{
|
||||
"script3", // Priority 1, Sequence 1
|
||||
"script4", // Priority 1, Sequence 2
|
||||
"script2", // Priority 1, Sequence 3
|
||||
"script1", // Priority 2, Sequence 1
|
||||
"script6", // Priority 2, Sequence 2
|
||||
"script5", // Priority 3, Sequence 1
|
||||
"a_script3", // Priority 1, Sequence 1, Name a_script3
|
||||
"b_script4", // Priority 1, Sequence 1, Name b_script4
|
||||
"script2", // Priority 1, Sequence 3
|
||||
"z_script1", // Priority 2, Sequence 1
|
||||
"script6", // Priority 2, Sequence 2
|
||||
"script5", // Priority 3, Sequence 1
|
||||
}
|
||||
|
||||
for i, expected := range expectedOrder {
|
||||
@@ -153,6 +163,13 @@ func TestScriptSorting(t *testing.T) {
|
||||
t.Errorf("Sequence not ascending at position %d with same priority %d: %d > %d",
|
||||
i, sortedScripts[i].Priority, sortedScripts[i].Sequence, sortedScripts[i+1].Sequence)
|
||||
}
|
||||
// Within same priority and sequence, names should be ascending
|
||||
if sortedScripts[i].Priority == sortedScripts[i+1].Priority &&
|
||||
sortedScripts[i].Sequence == sortedScripts[i+1].Sequence &&
|
||||
sortedScripts[i].Name > sortedScripts[i+1].Name {
|
||||
t.Errorf("Name not ascending at position %d with same priority/sequence: %s > %s",
|
||||
i, sortedScripts[i].Name, sortedScripts[i+1].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package writers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
@@ -28,3 +31,56 @@ type WriterOptions struct {
|
||||
// Additional options can be added here as needed
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// SanitizeFilename removes quotes, comments, and invalid characters from identifiers
|
||||
// to make them safe for use in filenames. This handles:
|
||||
// - Double and single quotes: "table_name" or 'table_name' -> table_name
|
||||
// - DBML comments: table [note: 'description'] -> table
|
||||
// - Invalid filename characters: replaced with underscores
|
||||
func SanitizeFilename(name string) string {
|
||||
// Remove DBML/DCTX style comments in brackets (e.g., [note: 'description'])
|
||||
commentRegex := regexp.MustCompile(`\s*\[.*?\]\s*`)
|
||||
name = commentRegex.ReplaceAllString(name, "")
|
||||
|
||||
// Remove quotes (both single and double)
|
||||
name = strings.ReplaceAll(name, `"`, "")
|
||||
name = strings.ReplaceAll(name, `'`, "")
|
||||
|
||||
// Remove backticks (MySQL style identifiers)
|
||||
name = strings.ReplaceAll(name, "`", "")
|
||||
|
||||
// Replace invalid filename characters with underscores
|
||||
// Invalid chars: / \ : * ? " < > | and control characters
|
||||
invalidChars := regexp.MustCompile(`[/\\:*?"<>|\x00-\x1f\x7f]`)
|
||||
name = invalidChars.ReplaceAllString(name, "_")
|
||||
|
||||
// Trim whitespace and consecutive underscores
|
||||
name = strings.TrimSpace(name)
|
||||
name = regexp.MustCompile(`_+`).ReplaceAllString(name, "_")
|
||||
name = strings.Trim(name, "_")
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// SanitizeStructTagValue sanitizes a value to be safely used inside Go struct tags.
|
||||
// Go struct tags are delimited by backticks, so any backtick in the value would break the syntax.
|
||||
// This function:
|
||||
// - Removes DBML/DCTX comments in brackets
|
||||
// - Removes all quotes (double, single, and backticks)
|
||||
// - Returns a clean identifier safe for use in struct tags and field names
|
||||
func SanitizeStructTagValue(value string) string {
|
||||
// Remove DBML/DCTX style comments in brackets (e.g., [note: 'description'])
|
||||
commentRegex := regexp.MustCompile(`\s*\[.*?\]\s*`)
|
||||
value = commentRegex.ReplaceAllString(value, "")
|
||||
|
||||
// Trim whitespace
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Remove all quotes: backticks, double quotes, and single quotes
|
||||
// This ensures the value is clean for use as Go identifiers and struct tag values
|
||||
value = strings.ReplaceAll(value, "`", "")
|
||||
value = strings.ReplaceAll(value, `"`, "")
|
||||
value = strings.ReplaceAll(value, `'`, "")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
5
tests/assets/dbml/multifile/1_users.dbml
Normal file
5
tests/assets/dbml/multifile/1_users.dbml
Normal file
@@ -0,0 +1,5 @@
|
||||
// First file - users table basic structure
|
||||
Table public.users {
|
||||
id bigint [pk, increment]
|
||||
email varchar(255) [unique, not null]
|
||||
}
|
||||
8
tests/assets/dbml/multifile/2_posts.dbml
Normal file
8
tests/assets/dbml/multifile/2_posts.dbml
Normal file
@@ -0,0 +1,8 @@
|
||||
// Second file - posts table
|
||||
Table public.posts {
|
||||
id bigint [pk, increment]
|
||||
user_id bigint [not null]
|
||||
title varchar(200) [not null]
|
||||
content text
|
||||
created_at timestamp [not null]
|
||||
}
|
||||
5
tests/assets/dbml/multifile/3_add_columns.dbml
Normal file
5
tests/assets/dbml/multifile/3_add_columns.dbml
Normal file
@@ -0,0 +1,5 @@
|
||||
// Third file - adds more columns to users table (tests merging)
|
||||
Table public.users {
|
||||
name varchar(100)
|
||||
created_at timestamp [not null]
|
||||
}
|
||||
10
tests/assets/dbml/multifile/9_refs.dbml
Normal file
10
tests/assets/dbml/multifile/9_refs.dbml
Normal file
@@ -0,0 +1,10 @@
|
||||
// File with commented-out refs - should load last
|
||||
// Contains relationships that depend on earlier tables
|
||||
|
||||
// Ref: public.posts.user_id > public.users.id [ondelete: CASCADE]
|
||||
|
||||
Table public.comments {
|
||||
id bigint [pk, increment]
|
||||
post_id bigint [not null]
|
||||
content text [not null]
|
||||
}
|
||||
13
vendor/github.com/gdamore/encoding/.appveyor.yml
generated
vendored
Normal file
13
vendor/github.com/gdamore/encoding/.appveyor.yml
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
version: 1.0.{build}
|
||||
clone_folder: c:\gopath\src\github.com\gdamore\encoding
|
||||
environment:
|
||||
GOPATH: c:\gopath
|
||||
build_script:
|
||||
- go version
|
||||
- go env
|
||||
- SET PATH=%LOCALAPPDATA%\atom\bin;%GOPATH%\bin;%PATH%
|
||||
- go get -t ./...
|
||||
- go build
|
||||
- go install ./...
|
||||
test_script:
|
||||
- go test ./...
|
||||
73
vendor/github.com/gdamore/encoding/CODE_OF_CONDUCT.md
generated
vendored
Normal file
73
vendor/github.com/gdamore/encoding/CODE_OF_CONDUCT.md
generated
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at garrett@damore.org. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
202
vendor/github.com/gdamore/encoding/LICENSE
generated
vendored
Normal file
202
vendor/github.com/gdamore/encoding/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
20
vendor/github.com/gdamore/encoding/README.md
generated
vendored
Normal file
20
vendor/github.com/gdamore/encoding/README.md
generated
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
## encoding
|
||||
|
||||
|
||||
[](https://github.com/gdamore/encoding/actions/workflows/linux.yml)
|
||||
[](https://github.com/gdamore/encoding/actions/workflows/windows.yml)
|
||||
[](https://github.com/gdamore/encoding/blob/master/LICENSE)
|
||||
[](https://codecov.io/gh/gdamore/encoding)
|
||||
[](https://godoc.org/github.com/gdamore/encoding)
|
||||
|
||||
Package encoding provides a number of encodings that are missing from the
|
||||
standard Go [encoding]("https://godoc.org/golang.org/x/text/encoding") package.
|
||||
|
||||
We hope that we can contribute these to the standard Go library someday. It
|
||||
turns out that some of these are useful for dealing with I/O streams coming
|
||||
from non-UTF friendly sources.
|
||||
|
||||
The UTF8 Encoder is also useful for situations where valid UTF-8 might be
|
||||
carried in streams that contain non-valid UTF; in particular I use it for
|
||||
helping me cope with terminals that embed escape sequences in otherwise
|
||||
valid UTF-8.
|
||||
12
vendor/github.com/gdamore/encoding/SECURITY.md
generated
vendored
Normal file
12
vendor/github.com/gdamore/encoding/SECURITY.md
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Security Policy
|
||||
|
||||
We take security very seriously in mangos, since you may be using it in
|
||||
Internet-facing applications.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, please contact us on our discord.
|
||||
You may also send an email to garrett@damore.org, or info@staysail.tech.
|
||||
|
||||
We will keep the reporter updated on any status updates on a regular basis,
|
||||
and will respond within two business days for any reported security issue.
|
||||
36
vendor/github.com/gdamore/encoding/ascii.go
generated
vendored
Normal file
36
vendor/github.com/gdamore/encoding/ascii.go
generated
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
)
|
||||
|
||||
// ASCII represents the 7-bit US-ASCII scheme. It decodes directly to
|
||||
// UTF-8 without change, as all ASCII values are legal UTF-8.
|
||||
// Unicode values less than 128 (i.e. 7 bits) map 1:1 with ASCII.
|
||||
// It encodes runes outside of that to 0x1A, the ASCII substitution character.
|
||||
var ASCII encoding.Encoding
|
||||
|
||||
func init() {
|
||||
amap := make(map[byte]rune)
|
||||
for i := 128; i <= 255; i++ {
|
||||
amap[byte(i)] = RuneError
|
||||
}
|
||||
|
||||
cm := &Charmap{Map: amap}
|
||||
cm.Init()
|
||||
ASCII = cm
|
||||
}
|
||||
195
vendor/github.com/gdamore/encoding/charmap.go
generated
vendored
Normal file
195
vendor/github.com/gdamore/encoding/charmap.go
generated
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
// Copyright 2024 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
const (
|
||||
// RuneError is an alias for the UTF-8 replacement rune, '\uFFFD'.
|
||||
RuneError = '\uFFFD'
|
||||
|
||||
// RuneSelf is the rune below which UTF-8 and the Unicode values are
|
||||
// identical. Its also the limit for ASCII.
|
||||
RuneSelf = 0x80
|
||||
|
||||
// ASCIISub is the ASCII substitution character.
|
||||
ASCIISub = '\x1a'
|
||||
)
|
||||
|
||||
// Charmap is a structure for setting up encodings for 8-bit character sets,
|
||||
// for transforming between UTF8 and that other character set. It has some
|
||||
// ideas borrowed from golang.org/x/text/encoding/charmap, but it uses a
|
||||
// different implementation. This implementation uses maps, and supports
|
||||
// user-defined maps.
|
||||
//
|
||||
// We do assume that a character map has a reasonable substitution character,
|
||||
// and that valid encodings are stable (exactly a 1:1 map) and stateless
|
||||
// (that is there is no shift character or anything like that.) Hence this
|
||||
// approach will not work for many East Asian character sets.
|
||||
//
|
||||
// Measurement shows little or no measurable difference in the performance of
|
||||
// the two approaches. The difference was down to a couple of nsec/op, and
|
||||
// no consistent pattern as to which ran faster. With the conversion to
|
||||
// UTF-8 the code takes about 25 nsec/op. The conversion in the reverse
|
||||
// direction takes about 100 nsec/op. (The larger cost for conversion
|
||||
// from UTF-8 is most likely due to the need to convert the UTF-8 byte stream
|
||||
// to a rune before conversion.
|
||||
type Charmap struct {
|
||||
transform.NopResetter
|
||||
bytes map[rune]byte
|
||||
runes [256][]byte
|
||||
once sync.Once
|
||||
|
||||
// The map between bytes and runes. To indicate that a specific
|
||||
// byte value is invalid for a charcter set, use the rune
|
||||
// utf8.RuneError. Values that are absent from this map will
|
||||
// be assumed to have the identity mapping -- that is the default
|
||||
// is to assume ISO8859-1, where all 8-bit characters have the same
|
||||
// numeric value as their Unicode runes. (Not to be confused with
|
||||
// the UTF-8 values, which *will* be different for non-ASCII runes.)
|
||||
//
|
||||
// If no values less than RuneSelf are changed (or have non-identity
|
||||
// mappings), then the character set is assumed to be an ASCII
|
||||
// superset, and certain assumptions and optimizations become
|
||||
// available for ASCII bytes.
|
||||
Map map[byte]rune
|
||||
|
||||
// The ReplacementChar is the byte value to use for substitution.
|
||||
// It should normally be ASCIISub for ASCII encodings. This may be
|
||||
// unset (left to zero) for mappings that are strictly ASCII supersets.
|
||||
// In that case ASCIISub will be assumed instead.
|
||||
ReplacementChar byte
|
||||
}
|
||||
|
||||
type cmapDecoder struct {
|
||||
transform.NopResetter
|
||||
runes [256][]byte
|
||||
}
|
||||
|
||||
type cmapEncoder struct {
|
||||
transform.NopResetter
|
||||
bytes map[rune]byte
|
||||
replace byte
|
||||
}
|
||||
|
||||
// Init initializes internal values of a character map. This should
|
||||
// be done early, to minimize the cost of allocation of transforms
|
||||
// later. It is not strictly necessary however, as the allocation
|
||||
// functions will arrange to call it if it has not already been done.
|
||||
func (c *Charmap) Init() {
|
||||
c.once.Do(c.initialize)
|
||||
}
|
||||
|
||||
func (c *Charmap) initialize() {
|
||||
c.bytes = make(map[rune]byte)
|
||||
ascii := true
|
||||
|
||||
for i := 0; i < 256; i++ {
|
||||
r, ok := c.Map[byte(i)]
|
||||
if !ok {
|
||||
r = rune(i)
|
||||
}
|
||||
if r < 128 && r != rune(i) {
|
||||
ascii = false
|
||||
}
|
||||
if r != RuneError {
|
||||
c.bytes[r] = byte(i)
|
||||
}
|
||||
utf := make([]byte, utf8.RuneLen(r))
|
||||
utf8.EncodeRune(utf, r)
|
||||
c.runes[i] = utf
|
||||
}
|
||||
if ascii && c.ReplacementChar == '\x00' {
|
||||
c.ReplacementChar = ASCIISub
|
||||
}
|
||||
}
|
||||
|
||||
// NewDecoder returns a Decoder the converts from the 8-bit
|
||||
// character set to UTF-8. Unknown mappings, if any, are mapped
|
||||
// to '\uFFFD'.
|
||||
func (c *Charmap) NewDecoder() *encoding.Decoder {
|
||||
c.Init()
|
||||
return &encoding.Decoder{Transformer: &cmapDecoder{runes: c.runes}}
|
||||
}
|
||||
|
||||
// NewEncoder returns a Transformer that converts from UTF8 to the
|
||||
// 8-bit character set. Unknown mappings are mapped to 0x1A.
|
||||
func (c *Charmap) NewEncoder() *encoding.Encoder {
|
||||
c.Init()
|
||||
return &encoding.Encoder{
|
||||
Transformer: &cmapEncoder{
|
||||
bytes: c.bytes,
|
||||
replace: c.ReplacementChar,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cmapDecoder) Transform(dst, src []byte, atEOF bool) (int, int, error) {
|
||||
var e error
|
||||
var ndst, nsrc int
|
||||
|
||||
for _, c := range src {
|
||||
b := d.runes[c]
|
||||
l := len(b)
|
||||
|
||||
if ndst+l > len(dst) {
|
||||
e = transform.ErrShortDst
|
||||
break
|
||||
}
|
||||
for i := 0; i < l; i++ {
|
||||
dst[ndst] = b[i]
|
||||
ndst++
|
||||
}
|
||||
nsrc++
|
||||
}
|
||||
return ndst, nsrc, e
|
||||
}
|
||||
|
||||
func (d *cmapEncoder) Transform(dst, src []byte, atEOF bool) (int, int, error) {
|
||||
var e error
|
||||
var ndst, nsrc int
|
||||
for nsrc < len(src) {
|
||||
if ndst >= len(dst) {
|
||||
e = transform.ErrShortDst
|
||||
break
|
||||
}
|
||||
|
||||
r, sz := utf8.DecodeRune(src[nsrc:])
|
||||
if r == utf8.RuneError && sz == 1 {
|
||||
// If its inconclusive due to insufficient data in
|
||||
// in the source, report it
|
||||
if atEOF && !utf8.FullRune(src[nsrc:]) {
|
||||
e = transform.ErrShortSrc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if c, ok := d.bytes[r]; ok {
|
||||
dst[ndst] = c
|
||||
} else {
|
||||
dst[ndst] = d.replace
|
||||
}
|
||||
nsrc += sz
|
||||
ndst++
|
||||
}
|
||||
|
||||
return ndst, nsrc, e
|
||||
}
|
||||
17
vendor/github.com/gdamore/encoding/doc.go
generated
vendored
Normal file
17
vendor/github.com/gdamore/encoding/doc.go
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package encoding provides a few of the encoding structures that are
|
||||
// missing from the Go x/text/encoding tree.
|
||||
package encoding
|
||||
273
vendor/github.com/gdamore/encoding/ebcdic.go
generated
vendored
Normal file
273
vendor/github.com/gdamore/encoding/ebcdic.go
generated
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
)
|
||||
|
||||
// EBCDIC represents the 8-bit EBCDIC scheme, found in some mainframe
|
||||
// environments. If you don't know what this is, consider yourself lucky.
|
||||
var EBCDIC encoding.Encoding
|
||||
|
||||
func init() {
|
||||
cm := &Charmap{
|
||||
ReplacementChar: '\x3f',
|
||||
Map: map[byte]rune{
|
||||
// 0x00-0x03 match
|
||||
0x04: RuneError,
|
||||
0x05: '\t',
|
||||
0x06: RuneError,
|
||||
0x07: '\x7f',
|
||||
0x08: RuneError,
|
||||
0x09: RuneError,
|
||||
0x0a: RuneError,
|
||||
// 0x0b-0x13 match
|
||||
0x14: RuneError,
|
||||
0x15: '\x85', // Not in any ISO code
|
||||
0x16: '\x08',
|
||||
0x17: RuneError,
|
||||
// 0x18-0x19 match
|
||||
0x1a: RuneError,
|
||||
0x1b: RuneError,
|
||||
// 0x1c-0x1f match
|
||||
0x20: RuneError,
|
||||
0x21: RuneError,
|
||||
0x22: RuneError,
|
||||
0x23: RuneError,
|
||||
0x24: RuneError,
|
||||
0x25: '\n',
|
||||
0x26: '\x17',
|
||||
0x27: '\x1b',
|
||||
0x28: RuneError,
|
||||
0x29: RuneError,
|
||||
0x2a: RuneError,
|
||||
0x2b: RuneError,
|
||||
0x2c: RuneError,
|
||||
0x2d: '\x05',
|
||||
0x2e: '\x06',
|
||||
0x2f: '\x07',
|
||||
0x30: RuneError,
|
||||
0x31: RuneError,
|
||||
0x32: '\x16',
|
||||
0x33: RuneError,
|
||||
0x34: RuneError,
|
||||
0x35: RuneError,
|
||||
0x36: RuneError,
|
||||
0x37: '\x04',
|
||||
0x38: RuneError,
|
||||
0x39: RuneError,
|
||||
0x3a: RuneError,
|
||||
0x3b: RuneError,
|
||||
0x3c: '\x14',
|
||||
0x3d: '\x15',
|
||||
0x3e: RuneError,
|
||||
0x3f: '\x1a', // also replacement char
|
||||
0x40: ' ',
|
||||
0x41: '\xa0',
|
||||
0x42: RuneError,
|
||||
0x43: RuneError,
|
||||
0x44: RuneError,
|
||||
0x45: RuneError,
|
||||
0x46: RuneError,
|
||||
0x47: RuneError,
|
||||
0x48: RuneError,
|
||||
0x49: RuneError,
|
||||
0x4a: RuneError,
|
||||
0x4b: '.',
|
||||
0x4c: '<',
|
||||
0x4d: '(',
|
||||
0x4e: '+',
|
||||
0x4f: '|',
|
||||
0x50: '&',
|
||||
0x51: RuneError,
|
||||
0x52: RuneError,
|
||||
0x53: RuneError,
|
||||
0x54: RuneError,
|
||||
0x55: RuneError,
|
||||
0x56: RuneError,
|
||||
0x57: RuneError,
|
||||
0x58: RuneError,
|
||||
0x59: RuneError,
|
||||
0x5a: '!',
|
||||
0x5b: '$',
|
||||
0x5c: '*',
|
||||
0x5d: ')',
|
||||
0x5e: ';',
|
||||
0x5f: '¬',
|
||||
0x60: '-',
|
||||
0x61: '/',
|
||||
0x62: RuneError,
|
||||
0x63: RuneError,
|
||||
0x64: RuneError,
|
||||
0x65: RuneError,
|
||||
0x66: RuneError,
|
||||
0x67: RuneError,
|
||||
0x68: RuneError,
|
||||
0x69: RuneError,
|
||||
0x6a: '¦',
|
||||
0x6b: ',',
|
||||
0x6c: '%',
|
||||
0x6d: '_',
|
||||
0x6e: '>',
|
||||
0x6f: '?',
|
||||
0x70: RuneError,
|
||||
0x71: RuneError,
|
||||
0x72: RuneError,
|
||||
0x73: RuneError,
|
||||
0x74: RuneError,
|
||||
0x75: RuneError,
|
||||
0x76: RuneError,
|
||||
0x77: RuneError,
|
||||
0x78: RuneError,
|
||||
0x79: '`',
|
||||
0x7a: ':',
|
||||
0x7b: '#',
|
||||
0x7c: '@',
|
||||
0x7d: '\'',
|
||||
0x7e: '=',
|
||||
0x7f: '"',
|
||||
0x80: RuneError,
|
||||
0x81: 'a',
|
||||
0x82: 'b',
|
||||
0x83: 'c',
|
||||
0x84: 'd',
|
||||
0x85: 'e',
|
||||
0x86: 'f',
|
||||
0x87: 'g',
|
||||
0x88: 'h',
|
||||
0x89: 'i',
|
||||
0x8a: RuneError,
|
||||
0x8b: RuneError,
|
||||
0x8c: RuneError,
|
||||
0x8d: RuneError,
|
||||
0x8e: RuneError,
|
||||
0x8f: '±',
|
||||
0x90: RuneError,
|
||||
0x91: 'j',
|
||||
0x92: 'k',
|
||||
0x93: 'l',
|
||||
0x94: 'm',
|
||||
0x95: 'n',
|
||||
0x96: 'o',
|
||||
0x97: 'p',
|
||||
0x98: 'q',
|
||||
0x99: 'r',
|
||||
0x9a: RuneError,
|
||||
0x9b: RuneError,
|
||||
0x9c: RuneError,
|
||||
0x9d: RuneError,
|
||||
0x9e: RuneError,
|
||||
0x9f: RuneError,
|
||||
0xa0: RuneError,
|
||||
0xa1: '~',
|
||||
0xa2: 's',
|
||||
0xa3: 't',
|
||||
0xa4: 'u',
|
||||
0xa5: 'v',
|
||||
0xa6: 'w',
|
||||
0xa7: 'x',
|
||||
0xa8: 'y',
|
||||
0xa9: 'z',
|
||||
0xaa: RuneError,
|
||||
0xab: RuneError,
|
||||
0xac: RuneError,
|
||||
0xad: RuneError,
|
||||
0xae: RuneError,
|
||||
0xaf: RuneError,
|
||||
0xb0: '^',
|
||||
0xb1: RuneError,
|
||||
0xb2: RuneError,
|
||||
0xb3: RuneError,
|
||||
0xb4: RuneError,
|
||||
0xb5: RuneError,
|
||||
0xb6: RuneError,
|
||||
0xb7: RuneError,
|
||||
0xb8: RuneError,
|
||||
0xb9: RuneError,
|
||||
0xba: '[',
|
||||
0xbb: ']',
|
||||
0xbc: RuneError,
|
||||
0xbd: RuneError,
|
||||
0xbe: RuneError,
|
||||
0xbf: RuneError,
|
||||
0xc0: '{',
|
||||
0xc1: 'A',
|
||||
0xc2: 'B',
|
||||
0xc3: 'C',
|
||||
0xc4: 'D',
|
||||
0xc5: 'E',
|
||||
0xc6: 'F',
|
||||
0xc7: 'G',
|
||||
0xc8: 'H',
|
||||
0xc9: 'I',
|
||||
0xca: '\xad', // NB: soft hyphen
|
||||
0xcb: RuneError,
|
||||
0xcc: RuneError,
|
||||
0xcd: RuneError,
|
||||
0xce: RuneError,
|
||||
0xcf: RuneError,
|
||||
0xd0: '}',
|
||||
0xd1: 'J',
|
||||
0xd2: 'K',
|
||||
0xd3: 'L',
|
||||
0xd4: 'M',
|
||||
0xd5: 'N',
|
||||
0xd6: 'O',
|
||||
0xd7: 'P',
|
||||
0xd8: 'Q',
|
||||
0xd9: 'R',
|
||||
0xda: RuneError,
|
||||
0xdb: RuneError,
|
||||
0xdc: RuneError,
|
||||
0xdd: RuneError,
|
||||
0xde: RuneError,
|
||||
0xdf: RuneError,
|
||||
0xe0: '\\',
|
||||
0xe1: '\u2007', // Non-breaking space
|
||||
0xe2: 'S',
|
||||
0xe3: 'T',
|
||||
0xe4: 'U',
|
||||
0xe5: 'V',
|
||||
0xe6: 'W',
|
||||
0xe7: 'X',
|
||||
0xe8: 'Y',
|
||||
0xe9: 'Z',
|
||||
0xea: RuneError,
|
||||
0xeb: RuneError,
|
||||
0xec: RuneError,
|
||||
0xed: RuneError,
|
||||
0xee: RuneError,
|
||||
0xef: RuneError,
|
||||
0xf0: '0',
|
||||
0xf1: '1',
|
||||
0xf2: '2',
|
||||
0xf3: '3',
|
||||
0xf4: '4',
|
||||
0xf5: '5',
|
||||
0xf6: '6',
|
||||
0xf7: '7',
|
||||
0xf8: '8',
|
||||
0xf9: '9',
|
||||
0xfa: RuneError,
|
||||
0xfb: RuneError,
|
||||
0xfc: RuneError,
|
||||
0xfd: RuneError,
|
||||
0xfe: RuneError,
|
||||
0xff: RuneError,
|
||||
}}
|
||||
cm.Init()
|
||||
EBCDIC = cm
|
||||
}
|
||||
33
vendor/github.com/gdamore/encoding/latin1.go
generated
vendored
Normal file
33
vendor/github.com/gdamore/encoding/latin1.go
generated
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
)
|
||||
|
||||
// ISO8859_1 represents the 8-bit ISO8859-1 scheme. It decodes directly to
|
||||
// UTF-8 without change, as all ISO8859-1 values are legal UTF-8.
|
||||
// Unicode values less than 256 (i.e. 8 bits) map 1:1 with 8859-1.
|
||||
// It encodes runes outside of that to 0x1A, the ASCII substitution character.
|
||||
var ISO8859_1 encoding.Encoding
|
||||
|
||||
func init() {
|
||||
cm := &Charmap{}
|
||||
cm.Init()
|
||||
|
||||
// 8859-1 is the 8-bit identity map for Unicode.
|
||||
ISO8859_1 = cm
|
||||
}
|
||||
35
vendor/github.com/gdamore/encoding/latin5.go
generated
vendored
Normal file
35
vendor/github.com/gdamore/encoding/latin5.go
generated
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
)
|
||||
|
||||
// ISO8859_9 represents the 8-bit ISO8859-9 scheme.
|
||||
var ISO8859_9 encoding.Encoding
|
||||
|
||||
func init() {
|
||||
cm := &Charmap{Map: map[byte]rune{
|
||||
0xD0: 'Ğ',
|
||||
0xDD: 'İ',
|
||||
0xDE: 'Ş',
|
||||
0xF0: 'ğ',
|
||||
0xFD: 'ı',
|
||||
0xFE: 'ş',
|
||||
}}
|
||||
cm.Init()
|
||||
ISO8859_9 = cm
|
||||
}
|
||||
35
vendor/github.com/gdamore/encoding/utf8.go
generated
vendored
Normal file
35
vendor/github.com/gdamore/encoding/utf8.go
generated
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright 2015 Garrett D'Amore
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
)
|
||||
|
||||
type validUtf8 struct{}
|
||||
|
||||
// UTF8 is an encoding for UTF-8. All it does is verify that the UTF-8
|
||||
// in is valid. The main reason for its existence is that it will detect
|
||||
// and report ErrSrcShort or ErrDstShort, whereas the Nop encoding just
|
||||
// passes every byte, blithely.
|
||||
var UTF8 encoding.Encoding = validUtf8{}
|
||||
|
||||
func (validUtf8) NewDecoder() *encoding.Decoder {
|
||||
return &encoding.Decoder{Transformer: encoding.UTF8Validator}
|
||||
}
|
||||
|
||||
func (validUtf8) NewEncoder() *encoding.Encoder {
|
||||
return &encoding.Encoder{Transformer: encoding.UTF8Validator}
|
||||
}
|
||||
13
vendor/github.com/gdamore/tcell/v2/.appveyor.yml
generated
vendored
Normal file
13
vendor/github.com/gdamore/tcell/v2/.appveyor.yml
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
version: 1.0.{build}
|
||||
clone_folder: c:\gopath\src\github.com\gdamore\tcell
|
||||
environment:
|
||||
GOPATH: c:\gopath
|
||||
build_script:
|
||||
- go version
|
||||
- go env
|
||||
- SET PATH=%LOCALAPPDATA%\atom\bin;%GOPATH%\bin;%PATH%
|
||||
- go get -t ./...
|
||||
- go build
|
||||
- go install ./...
|
||||
test_script:
|
||||
- go test ./...
|
||||
1
vendor/github.com/gdamore/tcell/v2/.gitignore
generated
vendored
Normal file
1
vendor/github.com/gdamore/tcell/v2/.gitignore
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
coverage.txt
|
||||
18
vendor/github.com/gdamore/tcell/v2/.travis.yml
generated
vendored
Normal file
18
vendor/github.com/gdamore/tcell/v2/.travis.yml
generated
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.15.x
|
||||
- master
|
||||
|
||||
arch:
|
||||
- amd64
|
||||
- ppc64le
|
||||
|
||||
before_install:
|
||||
- go get -t -v ./...
|
||||
|
||||
script:
|
||||
- go test -race -coverprofile=coverage.txt -covermode=atomic
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
4
vendor/github.com/gdamore/tcell/v2/AUTHORS
generated
vendored
Normal file
4
vendor/github.com/gdamore/tcell/v2/AUTHORS
generated
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Garrett D'Amore <garrett@damore.org>
|
||||
Zachary Yedidia <zyedidia@gmail.com>
|
||||
Junegunn Choi <junegunn.c@gmail.com>
|
||||
Staysail Systems, Inc. <info@staysail.tech>
|
||||
82
vendor/github.com/gdamore/tcell/v2/CHANGESv2.md
generated
vendored
Normal file
82
vendor/github.com/gdamore/tcell/v2/CHANGESv2.md
generated
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
## Breaking Changes in _Tcell_ v2
|
||||
|
||||
A number of changes were made to _Tcell_ for version two, and some of these are breaking.
|
||||
|
||||
### Import Path
|
||||
|
||||
The import path for tcell has changed to `github.com/gdamore/tcell/v2` to reflect a new major version.
|
||||
|
||||
### Style Is Not Numeric
|
||||
|
||||
The type `Style` has changed to a structure, to allow us to add additional data such as flags for color setting,
|
||||
more attribute bits, and so forth.
|
||||
Applications that relied on this being a number will need to be updated to use the accessor methods.
|
||||
|
||||
### Mouse Event Changes
|
||||
|
||||
The middle mouse button was reported as button 2 on Linux, but as button 3 on Windows,
|
||||
and the right mouse button was reported the reverse way.
|
||||
_Tcell_ now always reports the right mouse button as button 2, and the middle button as button 3.
|
||||
To help make this clearer, new symbols `ButtonPrimary`, `ButtonSecondary`, and
|
||||
`ButtonMiddle` are provided.
|
||||
(Note that which button is right vs. left may be impacted by user preferences.
|
||||
Usually the left button will be considered the Primary, and the right will be the Secondary.)
|
||||
Applications may need to adjust their handling of mouse buttons 2 and 3 accordingly.
|
||||
|
||||
### Terminals Removed
|
||||
|
||||
A number of terminals have been removed.
|
||||
These are mostly ancient definitions unlikely to be used by anyone, such as `adm3a`.
|
||||
|
||||
### High Number Function Keys
|
||||
|
||||
Historically terminfo reported function keys with modifiers set as a different
|
||||
function key altogether. For example, Shift-F1 was reported as F13 on XTerm.
|
||||
_Tcell_ now prefers to report these using the base key (such as F1) with modifiers added.
|
||||
This works on XTerm and VTE based emulators, but some emulators may not support this.
|
||||
The new behavior more closely aligns with behavior on Windows platforms.
|
||||
|
||||
## New Features in _Tcell_ v2
|
||||
|
||||
These features are not breaking, but are introduced in version 2.
|
||||
|
||||
### Improved Modifier Support
|
||||
|
||||
For terminals that appear to behave like the venerable XTerm, _tcell_
|
||||
automatically adds modifier reporting for ALT, CTRL, SHIFT, and META keys
|
||||
when the terminal reports them.
|
||||
|
||||
### Better Support for Palettes (Themes)
|
||||
|
||||
When using a color by its name or palette entry, _Tcell_ now tries to
|
||||
use that palette entry as is; this should avoid some inconsistency and respect
|
||||
terminal themes correctly.
|
||||
|
||||
When true fidelity to RGB values is needed, the new `TrueColor()` API can be used
|
||||
to create a direct color, which bypasses the palette altogether.
|
||||
|
||||
### Automatic TrueColor Detection
|
||||
|
||||
For some terminals, if the `Tc` or `RGB` properties are present in terminfo,
|
||||
_Tcell_ will automatically assume the terminal supports 24-bit color.
|
||||
|
||||
### ColorReset
|
||||
|
||||
A new color value, `ColorReset` can be used on the foreground or background
|
||||
to reset the color the default used by the terminal.
|
||||
|
||||
### tmux Support
|
||||
|
||||
_Tcell_ now has improved support for tmux, when the `$TERM` variable is set to "tmux".
|
||||
|
||||
### Strikethrough Support
|
||||
|
||||
_Tcell_ has support for strikethrough when the terminal supports it, using the new `StrikeThrough()` API.
|
||||
|
||||
### Bracketed Paste Support
|
||||
|
||||
_Tcell_ provides the long requested capability to discriminate paste event by using the
|
||||
bracketed-paste capability present in some terminals. This is automatically available on
|
||||
terminals that support XTerm style mouse handling, but applications must opt-in to this
|
||||
by using the new `EnablePaste()` function. A new `EventPaste` type of event will be
|
||||
delivered when starting and finishing a paste operation.
|
||||
202
vendor/github.com/gdamore/tcell/v2/LICENSE
generated
vendored
Normal file
202
vendor/github.com/gdamore/tcell/v2/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
61
vendor/github.com/gdamore/tcell/v2/README-wasm.md
generated
vendored
Normal file
61
vendor/github.com/gdamore/tcell/v2/README-wasm.md
generated
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# WASM for _Tcell_
|
||||
|
||||
You can build _Tcell_ project into a webpage by compiling it slightly differently. This will result in a _Tcell_ project you can embed into another html page, or use as a standalone page.
|
||||
|
||||
## Building your project
|
||||
|
||||
WASM needs special build flags in order to work. You can build it by executing
|
||||
```sh
|
||||
GOOS=js GOARCH=wasm go build -o yourfile.wasm
|
||||
```
|
||||
|
||||
## Additional files
|
||||
|
||||
You also need 5 other files in the same directory as the wasm. Four (`tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`) are provided in the `webfiles` directory. The last one, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing
|
||||
```sh
|
||||
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./
|
||||
```
|
||||
|
||||
In `tcell.js`, you also need to change the constant
|
||||
```js
|
||||
const wasmFilePath = "yourfile.wasm"
|
||||
```
|
||||
to the file you outputted to when building.
|
||||
|
||||
## Displaying your project
|
||||
|
||||
### Standalone
|
||||
|
||||
You can see the project (with an white background around the terminal) by serving the directory. You can do this using any framework, including another golang project:
|
||||
|
||||
```golang
|
||||
// server.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Fatal(http.ListenAndServe(":8080",
|
||||
http.FileServer(http.Dir("/path/to/dir/to/serve")),
|
||||
))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
To see the webpage with this example, you can type in `localhost:8080/tcell.html` into your browser while `server.go` is running.
|
||||
|
||||
### Embedding
|
||||
It is recommended to use an iframe if you want to embed the app into a webpage:
|
||||
```html
|
||||
<iframe src="tcell.html" title="Tcell app"></iframe>
|
||||
```
|
||||
|
||||
## Other considerations
|
||||
|
||||
### Accessing files
|
||||
|
||||
`io.Open(filename)` and other related functions for reading file systems do not work; use `http.Get(filename)` instead.
|
||||
290
vendor/github.com/gdamore/tcell/v2/README.md
generated
vendored
Normal file
290
vendor/github.com/gdamore/tcell/v2/README.md
generated
vendored
Normal file
@@ -0,0 +1,290 @@
|
||||
<img src="logos/tcell.png" style="float: right"/>
|
||||
|
||||
# Tcell
|
||||
|
||||
_Tcell_ is a _Go_ package that provides a cell based view for text terminals, like _XTerm_.
|
||||
It was inspired by _termbox_, but includes many additional improvements.
|
||||
|
||||
[](https://stand-with-ukraine.pp.ua)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/linux.yml)
|
||||
[](https://github.com/gdamore/tcell/actions/workflows/windows.yml)
|
||||
[](https://github.com/gdamore/tcell/blob/master/LICENSE)
|
||||
[](https://pkg.go.dev/github.com/gdamore/tcell/v2)
|
||||
[](https://discord.gg/urTTxDN)
|
||||
[](https://codecov.io/gh/gdamore/tcell)
|
||||
[](https://goreportcard.com/report/github.com/gdamore/tcell/v2)
|
||||
|
||||
Please see [here](UKRAINE.md) for an important message for the people of Russia.
|
||||
|
||||
NOTE: This is version 2 of _Tcell_. There are breaking changes relative to version 1.
|
||||
Version 1.x remains available using the import `github.com/gdamore/tcell`.
|
||||
|
||||
## Tutorial
|
||||
|
||||
A brief, and still somewhat rough, [tutorial](TUTORIAL.md) is available.
|
||||
|
||||
## Examples
|
||||
|
||||
- [proxima5](https://github.com/gdamore/proxima5) - space shooter ([video](https://youtu.be/jNxKTCmY_bQ))
|
||||
- [govisor](https://github.com/gdamore/govisor) - service management UI ([screenshot](http://2.bp.blogspot.com/--OsvnfzSNow/Vf7aqMw3zXI/AAAAAAAAARo/uOMtOvw4Sbg/s1600/Screen%2BShot%2B2015-09-20%2Bat%2B9.08.41%2BAM.png))
|
||||
- mouse demo - included mouse test ([screenshot](http://2.bp.blogspot.com/-fWvW5opT0es/VhIdItdKqJI/AAAAAAAAATE/7Ojc0L1SpB0/s1600/Screen%2BShot%2B2015-10-04%2Bat%2B11.47.13%2BPM.png))
|
||||
- [gomatrix](https://github.com/gdamore/gomatrix) - converted from Termbox
|
||||
- [micro](https://github.com/zyedidia/micro/) - lightweight text editor with syntax-highlighting and themes
|
||||
- [godu](https://github.com/viktomas/godu) - utility to discover large files/folders
|
||||
- [tview](https://github.com/rivo/tview/) - rich interactive widgets
|
||||
- [cview](https://code.rocketnine.space/tslocum/cview) - user interface toolkit (fork of _tview_)
|
||||
- [awesome gocui](https://github.com/awesome-gocui/gocui) - Go Console User Interface
|
||||
- [gomandelbrot](https://github.com/rgm3/gomandelbrot) - Mandelbrot!
|
||||
- [WTF](https://github.com/senorprogrammer/wtf) - personal information dashboard
|
||||
- [browsh](https://github.com/browsh-org/browsh) - modern web browser ([video](https://www.youtube.com/watch?v=HZq86XfBoRo))
|
||||
- [go-life](https://github.com/sachaos/go-life) - Conway's Game of Life
|
||||
- [gowid](https://github.com/gcla/gowid) - compositional widgets for terminal UIs, inspired by _urwid_
|
||||
- [termshark](https://termshark.io) - interface for _tshark_, inspired by Wireshark, built on _gowid_
|
||||
- [go-tetris](https://github.com/MichaelS11/go-tetris) - Go Tetris with AI option
|
||||
- [fzf](https://github.com/junegunn/fzf) - command-line fuzzy finder
|
||||
- [ascii-fluid](https://github.com/esimov/ascii-fluid) - fluid simulation controlled by webcam
|
||||
- [cbind](https://code.rocketnine.space/tslocum/cbind) - key event encoding, decoding and handling
|
||||
- [tpong](https://github.com/spinzed/tpong) - old-school Pong
|
||||
- [aerc](https://git.sr.ht/~sircmpwn/aerc) - email client
|
||||
- [tblogs](https://github.com/ezeoleaf/tblogs) - development blogs reader
|
||||
- [spinc](https://github.com/lallassu/spinc) - _irssi_ inspired chat application for Cisco Spark/WebEx
|
||||
- [gorss](https://github.com/lallassu/gorss) - RSS/Atom feed reader
|
||||
- [memoryalike](https://github.com/Bios-Marcel/memoryalike) - memorization game
|
||||
- [lf](https://github.com/gokcehan/lf) - file manager
|
||||
- [goful](https://github.com/anmitsu/goful) - CUI file manager
|
||||
- [gokeybr](https://github.com/bunyk/gokeybr) - deliberately practice your typing
|
||||
- [gonano](https://github.com/jbaramidze/gonano) - editor, mimics _nano_
|
||||
- [uchess](https://github.com/tmountain/uchess) - UCI chess client
|
||||
- [min](https://github.com/a-h/min) - Gemini browser
|
||||
- [ov](https://github.com/noborus/ov) - file pager
|
||||
- [tmux-wormhole](https://github.com/gcla/tmux-wormhole) - _tmux_ plugin to transfer files
|
||||
- [gruid-tcell](https://github.com/anaseto/gruid-tcell) - driver for the grid based UI and game framework
|
||||
- [aretext](https://github.com/aretext/aretext) - minimalist text editor with _vim_ key bindings
|
||||
- [sync](https://github.com/kyprifog/sync) - GitHub repo synchronization tool
|
||||
- [statusbar](https://github.com/kyprifog/statusbar) - statusbar motivation tool for tracking periodic tasks/goals
|
||||
- [todo](https://github.com/kyprifog/todo) - simple todo app
|
||||
- [gosnakego](https://github.com/liweiyi88/gosnakego) - a snake game
|
||||
- [gbb](https://github.com/sdemingo/gbb) - A classical bulletin board app for tildes or public unix servers
|
||||
- [lil](https://github.com/andrievsky/lil) - A simple and flexible interface for any service by implementing only list and get operations
|
||||
- [hero.go](https://github.com/barisbll/hero.go) - 2d monster shooter ([video](https://user-images.githubusercontent.com/40062673/277157369-240d7606-b471-4aa1-8c54-4379a513122b.mp4))
|
||||
- [go-tetris](https://github.com/aaronriekenberg/go-tetris) - simple tetris game for native terminal and WASM using github actions+pages
|
||||
- [oddshub](https://github.com/dos-2/oddshub) - A TUI designed for analyzing sports betting odds
|
||||
|
||||
## Pure Go Terminfo Database
|
||||
|
||||
_Tcell_ includes a full parser and expander for terminfo capability strings,
|
||||
so that it can avoid hard coding escape strings for formatting. It also favors
|
||||
portability, and includes support for all POSIX systems.
|
||||
|
||||
The database is also flexible & extensible, and can be modified by either running
|
||||
a program to build the entire database, or an entry for just a single terminal.
|
||||
|
||||
## More Portable
|
||||
|
||||
_Tcell_ is portable to a wide variety of systems, and is pure Go, without
|
||||
any need for CGO.
|
||||
_Tcell_ is believed to work with mainstream systems officially supported by golang.
|
||||
|
||||
## No Async IO
|
||||
|
||||
_Tcell_ is able to operate without requiring `SIGIO` signals (unlike _termbox_),
|
||||
or asynchronous I/O, and can instead use standard Go file objects and Go routines.
|
||||
This means it should be safe, especially for
|
||||
use with programs that use exec, or otherwise need to manipulate the tty streams.
|
||||
This model is also much closer to idiomatic Go, leading to fewer surprises.
|
||||
|
||||
## Rich Unicode & non-Unicode support
|
||||
|
||||
_Tcell_ includes enhanced support for Unicode, including wide characters and
|
||||
combining characters, provided your terminal can support them.
|
||||
Note that
|
||||
Windows terminals generally don't support the full Unicode repertoire.
|
||||
|
||||
It will also convert to and from Unicode locales, so that the program
|
||||
can work with UTF-8 internally, and get reasonable output in other locales.
|
||||
_Tcell_ tries hard to convert to native characters on both input and output.
|
||||
On output _Tcell_ even makes use of the alternate character set to facilitate
|
||||
drawing certain characters.
|
||||
|
||||
## More Function Keys
|
||||
|
||||
_Tcell_ also has richer support for a larger number of special keys that some
|
||||
terminals can send.
|
||||
|
||||
## Better Color Handling
|
||||
|
||||
_Tcell_ will respect your terminal's color space as specified within your terminfo entries.
|
||||
For example attempts to emit color sequences on VT100 terminals
|
||||
won't result in unintended consequences.
|
||||
|
||||
In legacy Windows mode, _Tcell_ supports 16 colors, bold, dim, and reverse,
|
||||
instead of just termbox's 8 colors with reverse. (Note that there is some
|
||||
conflation with bold/dim and colors.)
|
||||
Modern Windows 10 can benefit from much richer colors however.
|
||||
|
||||
_Tcell_ maps 16 colors down to 8, for terminals that need it.
|
||||
(The upper 8 colors are just brighter versions of the lower 8.)
|
||||
|
||||
## Better Mouse Support
|
||||
|
||||
_Tcell_ supports enhanced mouse tracking mode, so your application can receive
|
||||
regular mouse motion events, and wheel events, if your terminal supports it.
|
||||
|
||||
(Note: The Windows 10 Terminal application suffers from a flaw in this regard,
|
||||
and does not support mouse interaction. The stock Windows 10 console host
|
||||
fired up with cmd.exe or PowerShell works fine however.)
|
||||
|
||||
## _Termbox_ Compatibility
|
||||
|
||||
A compatibility layer for _termbox_ is provided in the `compat` directory.
|
||||
To use it, try importing `github.com/gdamore/tcell/termbox` instead.
|
||||
Most _termbox-go_ programs will probably work without further modification.
|
||||
|
||||
## Working With Unicode
|
||||
|
||||
Internally _Tcell_ uses UTF-8, just like Go.
|
||||
However, _Tcell_ understands how to
|
||||
convert to and from other character sets, using the capabilities of
|
||||
the `golang.org/x/text/encoding` packages.
|
||||
Your application must supply
|
||||
them, as the full set of the most common ones bloats the program by about 2 MB.
|
||||
If you're lazy, and want them all anyway, see the `encoding` sub-directory.
|
||||
|
||||
## Wide & Combining Characters
|
||||
|
||||
The `SetContent()` API takes a primary rune, and an optional list of combining runes.
|
||||
If any of the runes is a wide (East Asian) rune occupying two cells,
|
||||
then the library will skip output from the following cell. Care must be
|
||||
taken in the application to avoid explicitly attempting to set content in the
|
||||
next cell, otherwise the results are undefined. (Normally the wide character
|
||||
is displayed, and the other character is not; do not depend on that behavior.)
|
||||
|
||||
Older terminal applications (especially on systems like Windows 8) lack support
|
||||
for advanced Unicode, and thus may not fare well.
|
||||
|
||||
## Colors
|
||||
|
||||
_Tcell_ assumes the ANSI/XTerm color model, including the 256 color map that
|
||||
XTerm uses when it supports 256 colors. The terminfo guidance will be
|
||||
honored, with respect to the number of colors supported. Also, only
|
||||
terminals which expose ANSI style `setaf` and `setab` will support color;
|
||||
if you have a color terminal that only has `setf` and `setb`, please submit
|
||||
a ticket.
|
||||
|
||||
## 24-bit Color
|
||||
|
||||
_Tcell_ _supports 24-bit color!_ (That is, if your terminal can support it.)
|
||||
|
||||
NOTE: Technically the approach of using 24-bit RGB values for color is more
|
||||
accurately described as "direct color", but most people use the term "true color".
|
||||
We follow the (inaccurate) common convention.
|
||||
|
||||
There are a few ways you can enable (or disable) true color.
|
||||
|
||||
- For many terminals, we can detect it automatically if your terminal
|
||||
includes the `RGB` or `Tc` capabilities (or rather it did when the database
|
||||
was updated.)
|
||||
|
||||
- You can force this one by setting the `COLORTERM` environment variable to
|
||||
`24-bit`, `truecolor` or `24bit`. This is the same method used
|
||||
by most other terminal applications that support 24-bit color.
|
||||
|
||||
- If you set your `TERM` environment variable to a value with the suffix `-truecolor`
|
||||
then 24-bit color compatible with XTerm and ECMA-48 will be assumed.
|
||||
(This feature is deprecated.
|
||||
It is recommended to use one of other methods listed above.)
|
||||
|
||||
- You can disable 24-bit color by setting `TCELL_TRUECOLOR=disable` in your
|
||||
environment.
|
||||
|
||||
When using TrueColor, programs will display the colors that the programmer
|
||||
intended, overriding any "`themes`" you may have set in your terminal
|
||||
emulator. (For some cases, accurate color fidelity is more important
|
||||
than respecting themes. For other cases, such as typical text apps that
|
||||
only use a few colors, its more desirable to respect the themes that
|
||||
the user has established.)
|
||||
|
||||
## Performance
|
||||
|
||||
Reasonable attempts have been made to minimize sending data to terminals,
|
||||
avoiding repeated sequences or drawing the same cell on refresh updates.
|
||||
|
||||
## Terminfo
|
||||
|
||||
(Not relevant for Windows users.)
|
||||
|
||||
The Terminfo implementation operates with a built-in database.
|
||||
This should satisfy most users. However, it can also (on systems
|
||||
with ncurses installed), dynamically parse the output from `infocmp`
|
||||
for terminals it does not already know about.
|
||||
|
||||
See the `terminfo/` directory for more information about generating
|
||||
new entries for the built-in database.
|
||||
|
||||
_Tcell_ requires that the terminal support the `cup` mode of cursor addressing.
|
||||
Ancient terminals without the ability to position the cursor directly
|
||||
are not supported.
|
||||
This is unlikely to be a problem; such terminals have not been mass-produced
|
||||
since the early 1970s.
|
||||
|
||||
## Mouse Support
|
||||
|
||||
Mouse support is detected via the `kmous` terminfo variable, however,
|
||||
enablement/disablement and decoding mouse events is done using hard coded
|
||||
sequences based on the XTerm X11 model. All popular
|
||||
terminals with mouse tracking support this model. (Full terminfo support
|
||||
is not possible as terminfo sequences are not defined.)
|
||||
|
||||
On Windows, the mouse works normally.
|
||||
|
||||
Mouse wheel buttons on various terminals are known to work, but the support
|
||||
in terminal emulators, as well as support for various buttons and
|
||||
live mouse tracking, varies widely.
|
||||
Modern _xterm_, macOS _Terminal_, and _iTerm_ all work well.
|
||||
|
||||
## Bracketed Paste
|
||||
|
||||
Terminals that appear to support the XTerm mouse model also can support
|
||||
bracketed paste, for applications that opt-in. See `EnablePaste()` for details.
|
||||
|
||||
## Testability
|
||||
|
||||
There is a `SimulationScreen`, that can be used to simulate a real screen
|
||||
for automated testing. The supplied tests do this. The simulation contains
|
||||
event delivery, screen resizing support, and capabilities to inject events
|
||||
and examine "`physical`" screen contents.
|
||||
|
||||
## Platforms
|
||||
|
||||
### POSIX (Linux, FreeBSD, macOS, Solaris, etc.)
|
||||
|
||||
Everything works using pure Go on mainstream platforms. Some more esoteric
|
||||
platforms (e.g., AIX) may need to be added. Pull requests are welcome!
|
||||
|
||||
### Windows
|
||||
|
||||
Windows console mode applications are supported.
|
||||
|
||||
Modern console applications like ConEmu and the Windows 10 terminal,
|
||||
support all the good features (resize, mouse tracking, etc.)
|
||||
|
||||
### WASM
|
||||
|
||||
WASM is supported, but needs additional setup detailed in [README-wasm](README-wasm.md).
|
||||
|
||||
### Plan9 and others
|
||||
|
||||
These platforms won't work, but compilation stubs are supplied
|
||||
for folks that want to include parts of this in software for those
|
||||
platforms. The Simulation screen works, but as _Tcell_ doesn't know how to
|
||||
allocate a real screen object on those platforms, `NewScreen()` will fail.
|
||||
|
||||
If anyone has wisdom about how to improve support for these,
|
||||
please let me know. PRs are especially welcome.
|
||||
|
||||
### Commercial Support
|
||||
|
||||
_Tcell_ is absolutely free, but if you want to obtain commercial, professional support, there are options.
|
||||
|
||||
- [TideLift](https://tidelift.com/) subscriptions include support for _Tcell_, as well as many other open source packages.
|
||||
- [Staysail Systems Inc.](mailto:info@staysail.tech) offers direct support, and custom development around _Tcell_ on an hourly basis.
|
||||
15
vendor/github.com/gdamore/tcell/v2/SECURITY.md
generated
vendored
Normal file
15
vendor/github.com/gdamore/tcell/v2/SECURITY.md
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# SECURITY
|
||||
|
||||
It's somewhat unlikely that tcell is in a security sensitive path,
|
||||
but we do take security seriously.
|
||||
|
||||
## Vulnerabilityu Response
|
||||
|
||||
If you report a vulnerability, we will respond within 2 business days.
|
||||
|
||||
## Report a Vulnerability
|
||||
|
||||
If you wish to report a vulnerability found in tcell, simply send a message
|
||||
to garrett@damore.org. You may also reach us on our discord channel -
|
||||
https://discord.gg/urTTxDN - a private message to `gdamore` on that channel
|
||||
may be submitted instead of mail.
|
||||
313
vendor/github.com/gdamore/tcell/v2/TUTORIAL.md
generated
vendored
Normal file
313
vendor/github.com/gdamore/tcell/v2/TUTORIAL.md
generated
vendored
Normal file
@@ -0,0 +1,313 @@
|
||||
# _Tcell_ Tutorial
|
||||
|
||||
_Tcell_ provides a low-level, portable API for building terminal-based programs.
|
||||
A [terminal emulator](https://en.wikipedia.org/wiki/Terminal_emulator)
|
||||
(or a real terminal such as a DEC VT-220) is used to interact with such a program.
|
||||
|
||||
_Tcell_'s interface is fairly low-level.
|
||||
While it provides a reasonably portable way of dealing with all the usual terminal
|
||||
features, it may be easier to utilize a higher level framework.
|
||||
A number of such frameworks are listed on the _Tcell_ main [README](README.md).
|
||||
|
||||
This tutorial provides the details of _Tcell_, and is appropriate for developers
|
||||
wishing to create their own application frameworks or needing more direct access
|
||||
to the terminal capabilities.
|
||||
|
||||
## Resize events
|
||||
|
||||
Applications receive an event of type `EventResize` when they are first initialized and each time the terminal is resized.
|
||||
The new size is available as `Size`.
|
||||
|
||||
```go
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventResize:
|
||||
w, h := ev.Size()
|
||||
logMessage(fmt.Sprintf("Resized to %dx%d", w, h))
|
||||
}
|
||||
```
|
||||
|
||||
## Key events
|
||||
|
||||
When a key is pressed, applications receive an event of type `EventKey`.
|
||||
This event describes the modifier keys pressed (if any) and the pressed key or rune.
|
||||
|
||||
When a rune key is pressed, an event with its `Key` set to `KeyRune` is dispatched.
|
||||
|
||||
When a non-rune key is pressed, it is available as the `Key` of the event.
|
||||
|
||||
```go
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventKey:
|
||||
mod, key, ch := ev.Mod(), ev.Key(), ev.Rune()
|
||||
logMessage(fmt.Sprintf("EventKey Modifiers: %d Key: %d Rune: %d", mod, key, ch))
|
||||
}
|
||||
```
|
||||
|
||||
### Key event restrictions
|
||||
|
||||
Terminal-based programs have less visibility into keyboard activity than graphical applications.
|
||||
|
||||
When a key is pressed and held, additional key press events are sent by the terminal emulator.
|
||||
The rate of these repeated events depends on the emulator's configuration.
|
||||
Key release events are not available.
|
||||
|
||||
It is not possible to distinguish runes typed while holding shift and runes typed using caps lock.
|
||||
Capital letters are reported without the Shift modifier.
|
||||
|
||||
## Mouse events
|
||||
|
||||
Applications receive an event of type `EventMouse` when the mouse moves, or a mouse button is pressed or released.
|
||||
Mouse events are only delivered if
|
||||
`EnableMouse` has been called.
|
||||
|
||||
The mouse buttons being pressed (if any) are available as `Buttons`, and the position of the mouse is available as `Position`.
|
||||
|
||||
```go
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventMouse:
|
||||
mod := ev.Modifiers()
|
||||
btns := ev.Buttons()
|
||||
x, y := ev.Position()
|
||||
logMessage(fmt.Sprintf("EventMouse Modifiers: %d Buttons: %d Position: %d,%d", mod, btns, x, y))
|
||||
}
|
||||
```
|
||||
|
||||
### Mouse buttons
|
||||
|
||||
Identifier | Alias | Description
|
||||
-----------|-----------------|-----------
|
||||
Button1 | ButtonPrimary | Left button
|
||||
Button2 | ButtonSecondary | Right button
|
||||
Button3 | ButtonMiddle | Middle button
|
||||
Button4 | | Side button (thumb/next)
|
||||
Button5 | | Side button (thumb/prev)
|
||||
WheelUp | | Scroll wheel up
|
||||
WheelDown | | Scroll wheel down
|
||||
WheelLeft | | Horizontal wheel left
|
||||
WheelRight | | Horizontal wheel right
|
||||
|
||||
## Usage
|
||||
|
||||
To create a _Tcell_ application, first initialize a screen to hold it.
|
||||
|
||||
```go
|
||||
s, err := tcell.NewScreen()
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", err)
|
||||
}
|
||||
if err := s.Init(); err != nil {
|
||||
log.Fatalf("%+v", err)
|
||||
}
|
||||
|
||||
// Set default text style
|
||||
defStyle := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorReset)
|
||||
s.SetStyle(defStyle)
|
||||
|
||||
// Clear screen
|
||||
s.Clear()
|
||||
```
|
||||
|
||||
Text may be drawn on the screen using `SetContent`.
|
||||
|
||||
```go
|
||||
s.SetContent(0, 0, 'H', nil, defStyle)
|
||||
s.SetContent(1, 0, 'i', nil, defStyle)
|
||||
s.SetContent(2, 0, '!', nil, defStyle)
|
||||
```
|
||||
|
||||
To draw text more easily, define a render function.
|
||||
|
||||
```go
|
||||
func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
|
||||
row := y1
|
||||
col := x1
|
||||
for _, r := range []rune(text) {
|
||||
s.SetContent(col, row, r, nil, style)
|
||||
col++
|
||||
if col >= x2 {
|
||||
row++
|
||||
col = x1
|
||||
}
|
||||
if row > y2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lastly, define an event loop to handle user input and update application state.
|
||||
|
||||
```go
|
||||
quit := func() {
|
||||
s.Fini()
|
||||
os.Exit(0)
|
||||
}
|
||||
for {
|
||||
// Update screen
|
||||
s.Show()
|
||||
|
||||
// Poll event
|
||||
ev := s.PollEvent()
|
||||
|
||||
// Process event
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventResize:
|
||||
s.Sync()
|
||||
case *tcell.EventKey:
|
||||
if ev.Key() == tcell.KeyEscape || ev.Key() == tcell.KeyCtrlC {
|
||||
quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Demo application
|
||||
|
||||
The following demonstrates how to initialize a screen, draw text/graphics and handle user input.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
|
||||
row := y1
|
||||
col := x1
|
||||
for _, r := range []rune(text) {
|
||||
s.SetContent(col, row, r, nil, style)
|
||||
col++
|
||||
if col >= x2 {
|
||||
row++
|
||||
col = x1
|
||||
}
|
||||
if row > y2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawBox(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
|
||||
if y2 < y1 {
|
||||
y1, y2 = y2, y1
|
||||
}
|
||||
if x2 < x1 {
|
||||
x1, x2 = x2, x1
|
||||
}
|
||||
|
||||
// Fill background
|
||||
for row := y1; row <= y2; row++ {
|
||||
for col := x1; col <= x2; col++ {
|
||||
s.SetContent(col, row, ' ', nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw borders
|
||||
for col := x1; col <= x2; col++ {
|
||||
s.SetContent(col, y1, tcell.RuneHLine, nil, style)
|
||||
s.SetContent(col, y2, tcell.RuneHLine, nil, style)
|
||||
}
|
||||
for row := y1 + 1; row < y2; row++ {
|
||||
s.SetContent(x1, row, tcell.RuneVLine, nil, style)
|
||||
s.SetContent(x2, row, tcell.RuneVLine, nil, style)
|
||||
}
|
||||
|
||||
// Only draw corners if necessary
|
||||
if y1 != y2 && x1 != x2 {
|
||||
s.SetContent(x1, y1, tcell.RuneULCorner, nil, style)
|
||||
s.SetContent(x2, y1, tcell.RuneURCorner, nil, style)
|
||||
s.SetContent(x1, y2, tcell.RuneLLCorner, nil, style)
|
||||
s.SetContent(x2, y2, tcell.RuneLRCorner, nil, style)
|
||||
}
|
||||
|
||||
drawText(s, x1+1, y1+1, x2-1, y2-1, style, text)
|
||||
}
|
||||
|
||||
func main() {
|
||||
defStyle := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorReset)
|
||||
boxStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorPurple)
|
||||
|
||||
// Initialize screen
|
||||
s, err := tcell.NewScreen()
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", err)
|
||||
}
|
||||
if err := s.Init(); err != nil {
|
||||
log.Fatalf("%+v", err)
|
||||
}
|
||||
s.SetStyle(defStyle)
|
||||
s.EnableMouse()
|
||||
s.EnablePaste()
|
||||
s.Clear()
|
||||
|
||||
// Draw initial boxes
|
||||
drawBox(s, 1, 1, 42, 7, boxStyle, "Click and drag to draw a box")
|
||||
drawBox(s, 5, 9, 32, 14, boxStyle, "Press C to reset")
|
||||
|
||||
quit := func() {
|
||||
// You have to catch panics in a defer, clean up, and
|
||||
// re-raise them - otherwise your application can
|
||||
// die without leaving any diagnostic trace.
|
||||
maybePanic := recover()
|
||||
s.Fini()
|
||||
if maybePanic != nil {
|
||||
panic(maybePanic)
|
||||
}
|
||||
}
|
||||
defer quit()
|
||||
|
||||
// Here's how to get the screen size when you need it.
|
||||
// xmax, ymax := s.Size()
|
||||
|
||||
// Here's an example of how to inject a keystroke where it will
|
||||
// be picked up by the next PollEvent call. Note that the
|
||||
// queue is LIFO, it has a limited length, and PostEvent() can
|
||||
// return an error.
|
||||
// s.PostEvent(tcell.NewEventKey(tcell.KeyRune, rune('a'), 0))
|
||||
|
||||
// Event loop
|
||||
ox, oy := -1, -1
|
||||
for {
|
||||
// Update screen
|
||||
s.Show()
|
||||
|
||||
// Poll event
|
||||
ev := s.PollEvent()
|
||||
|
||||
// Process event
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventResize:
|
||||
s.Sync()
|
||||
case *tcell.EventKey:
|
||||
if ev.Key() == tcell.KeyEscape || ev.Key() == tcell.KeyCtrlC {
|
||||
return
|
||||
} else if ev.Key() == tcell.KeyCtrlL {
|
||||
s.Sync()
|
||||
} else if ev.Rune() == 'C' || ev.Rune() == 'c' {
|
||||
s.Clear()
|
||||
}
|
||||
case *tcell.EventMouse:
|
||||
x, y := ev.Position()
|
||||
|
||||
switch ev.Buttons() {
|
||||
case tcell.Button1, tcell.Button2:
|
||||
if ox < 0 {
|
||||
ox, oy = x, y // record location when click started
|
||||
}
|
||||
|
||||
case tcell.ButtonNone:
|
||||
if ox >= 0 {
|
||||
label := fmt.Sprintf("%d,%d to %d,%d", ox, oy, x, y)
|
||||
drawBox(s, ox, oy, x, y, boxStyle, label)
|
||||
ox, oy = -1, -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
77
vendor/github.com/gdamore/tcell/v2/UKRAINE.md
generated
vendored
Normal file
77
vendor/github.com/gdamore/tcell/v2/UKRAINE.md
generated
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
# Ukraine, Russia, and a World Tragedy
|
||||
|
||||
## A message to those inside Russia
|
||||
|
||||
### Written March 4, 2022.
|
||||
|
||||
It is with a very heavy heart that I write this. I am normally opposed to the use of open source
|
||||
projects to communicate political positions or advocate for things outside the immediate relevancy
|
||||
to that project.
|
||||
|
||||
However, the events occurring in Ukraine, and specifically the unprecedented invasion of Ukraine by
|
||||
Russian forces operating under orders from Russian President Vladimir Putin compel me to speak out.
|
||||
|
||||
Those who know me, know that I have family, friends, and colleagues in Russia, and Ukraine both. My closest friends
|
||||
have historically been Russian friends my wife's hometown of Chelyabinsk. I myself have in the past
|
||||
frequently traveled to Russia, and indeed operated a software development firm with offices in St. Petersburg.
|
||||
I had a special kinship with Russia and its people.
|
||||
|
||||
I say "had", because I fear that the actions of Putin, and the massive disinformation campaign that his regime
|
||||
has waged inside Russia, mean that it's likely that I won't see those friends again. At present, I'm not sure
|
||||
my wife will see her own mother again. We no longer feel it's safe for either of us to return Russia given
|
||||
actions taken by the regime to crack down on those who express disagreement.
|
||||
|
||||
Russian citizens are being led to believe it is acting purely defensively, and that only legitimate military
|
||||
targets are being targeted, and that all the information we have received in the West are fakes.
|
||||
|
||||
I am confident that nothing could be further from the truth.
|
||||
|
||||
This has caused many in Russia, including people whom I respect and believe to be smarter than this, to
|
||||
stand by Putin, and endorse his actions. The claim is that the entirety of NATO is operating at the behest
|
||||
of the USA, and that the entirety of Europe was poised to attack Russia. While this is clearly absurd to those
|
||||
of us with any understanding of western politics, Russian citizens are being fed this lie, and believing it.
|
||||
|
||||
If you're reading this from inside Russia -- YOU are the person that I hope this message reaches. Your
|
||||
government is LYING to you. Of course, all governments lie all the time. But consider this. Almost the
|
||||
entire world has condemned the invasion of Ukraine as criminal, and has applied sanctions. Even countries
|
||||
which have poor relations with the US sanctioning Russia, as well as nations which historically have remained
|
||||
neutral. (Famously neutral -- even during World War II, Switzerland has acted to apply sanctions in
|
||||
concert with the rest of the world.)
|
||||
|
||||
Ask yourself, why does Putin fear a free press so much, if what he says is true? Why the crack-downs on
|
||||
children expressing only a desire for peace with Ukraine? Why would the entire world unified against him,
|
||||
if Putin was in the right? Why would the only countries that stood with Russia against
|
||||
the UN resolution to condemn these acts as crimes be Belarus, North Korea, and Syria? Even countries normally
|
||||
allied to Russia could not bring themselves to do more than abstain from the vote to condemn it.
|
||||
|
||||
To be clear, I do not claim that the actions taken by the West or by the Ukrainian government were completely
|
||||
blameless. On the contrary, I understand that Western media is biased, and the truth is rarely exactly
|
||||
as reported. I believe that there is a kernel of truth in the claims of fascists and ultra-nationalist
|
||||
militias operating in Ukraine and specifically Donbas. However, I am also equally certain that Putin's
|
||||
response is out of proportion, and that concerns about such militias are principally just a pretext to justify
|
||||
an invasion.
|
||||
|
||||
Europe is at war, unlike we've seen in my lifetime. The world is more divided, and closer to nuclear holocaust
|
||||
than it has been since the Cold War. And that is 100% the fault of Putin.
|
||||
|
||||
While Putin remains in power, there cannot really be any way for Russian international relations to return
|
||||
to normal. Putin has set your country on a path to return to the Cold War, likely because he fancies himself
|
||||
to be a new Stalin. However, unlike the Soviet Union, the Russian economy does not have the wherewithal to
|
||||
stand on its own, and the invasion of Ukraine has fully ensured that Russia will not find any friends anywhere
|
||||
else in Europe, and probably few places in Asia.
|
||||
|
||||
The *only* paths forward for Russia are either a Russia without Putin (and those who would support his agenda),
|
||||
or a complete breakdown of Russian prosperity, likely followed by the increasing international conflict that will
|
||||
be the natural escalation from a country that is isolated and impoverished. Those of us observing from the West are
|
||||
gravely concerned, because we cannot see any end to this madness that does not result in nuclear conflict,
|
||||
unless from within.
|
||||
|
||||
In the meantime, the worst prices will be paid for by innocents in Ukraine, and by young Russian mean
|
||||
forced to carry out the orders of Putin's corrupt regime.
|
||||
|
||||
And *that* is why I write this -- to appeal to those within Russia to open your eyes, and think with
|
||||
your minds. It is right and proper to be proud of your country and its rich heritage. But it is also
|
||||
right and proper to look for ways to save it from the ruinous path that its current leadership has set it upon,
|
||||
and to recognize when that leadership is no longer acting in interest of the country or its people.
|
||||
|
||||
- Garrett D'Amore, March 4, 2022
|
||||
34
vendor/github.com/gdamore/tcell/v2/attr.go
generated
vendored
Normal file
34
vendor/github.com/gdamore/tcell/v2/attr.go
generated
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright 2024 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
// AttrMask represents a mask of text attributes, apart from color.
|
||||
// Note that support for attributes may vary widely across terminals.
|
||||
type AttrMask uint
|
||||
|
||||
// Attributes are not colors, but affect the display of text. They can
|
||||
// be combined, in some cases, but not others. (E.g. you can have Dim Italic,
|
||||
// but only CurlyUnderline cannot be mixed with DottedUnderline.)
|
||||
const (
|
||||
AttrBold AttrMask = 1 << iota
|
||||
AttrBlink
|
||||
AttrReverse
|
||||
AttrUnderline // Deprecated: Use UnderlineStyle
|
||||
AttrDim
|
||||
AttrItalic
|
||||
AttrStrikeThrough
|
||||
AttrInvalid AttrMask = 1 << 31 // Mark the style or attributes invalid
|
||||
AttrNone AttrMask = 0 // Just normal text.
|
||||
)
|
||||
249
vendor/github.com/gdamore/tcell/v2/cell.go
generated
vendored
Normal file
249
vendor/github.com/gdamore/tcell/v2/cell.go
generated
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright 2024 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type cell struct {
|
||||
currMain rune
|
||||
currComb []rune
|
||||
currStyle Style
|
||||
lastMain rune
|
||||
lastStyle Style
|
||||
lastComb []rune
|
||||
width int
|
||||
lock bool
|
||||
}
|
||||
|
||||
// CellBuffer represents a two-dimensional array of character cells.
|
||||
// This is primarily intended for use by Screen implementors; it
|
||||
// contains much of the common code they need. To create one, just
|
||||
// declare a variable of its type; no explicit initialization is necessary.
|
||||
//
|
||||
// CellBuffer is not thread safe.
|
||||
type CellBuffer struct {
|
||||
w int
|
||||
h int
|
||||
cells []cell
|
||||
}
|
||||
|
||||
// SetContent sets the contents (primary rune, combining runes,
|
||||
// and style) for a cell at a given location. If the background or
|
||||
// foreground of the style is set to ColorNone, then the respective
|
||||
// color is left un changed.
|
||||
func (cb *CellBuffer) SetContent(x int, y int,
|
||||
mainc rune, combc []rune, style Style,
|
||||
) {
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
|
||||
// Wide characters: we want to mark the "wide" cells
|
||||
// dirty as well as the base cell, to make sure we consider
|
||||
// both cells as dirty together. We only need to do this
|
||||
// if we're changing content
|
||||
if (c.width > 0) && (mainc != c.currMain || len(combc) != len(c.currComb) || (len(combc) > 0 && !reflect.DeepEqual(combc, c.currComb))) {
|
||||
for i := 0; i < c.width; i++ {
|
||||
cb.SetDirty(x+i, y, true)
|
||||
}
|
||||
}
|
||||
|
||||
c.currComb = append([]rune{}, combc...)
|
||||
|
||||
if c.currMain != mainc {
|
||||
c.width = runewidth.RuneWidth(mainc)
|
||||
}
|
||||
c.currMain = mainc
|
||||
if style.fg == ColorNone {
|
||||
style.fg = c.currStyle.fg
|
||||
}
|
||||
if style.bg == ColorNone {
|
||||
style.bg = c.currStyle.bg
|
||||
}
|
||||
c.currStyle = style
|
||||
}
|
||||
}
|
||||
|
||||
// GetContent returns the contents of a character cell, including the
|
||||
// primary rune, any combining character runes (which will usually be
|
||||
// nil), the style, and the display width in cells. (The width can be
|
||||
// either 1, normally, or 2 for East Asian full-width characters.)
|
||||
func (cb *CellBuffer) GetContent(x, y int) (rune, []rune, Style, int) {
|
||||
var mainc rune
|
||||
var combc []rune
|
||||
var style Style
|
||||
var width int
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
mainc, combc, style = c.currMain, c.currComb, c.currStyle
|
||||
if width = c.width; width == 0 || mainc < ' ' {
|
||||
width = 1
|
||||
mainc = ' '
|
||||
}
|
||||
}
|
||||
return mainc, combc, style, width
|
||||
}
|
||||
|
||||
// Size returns the (width, height) in cells of the buffer.
|
||||
func (cb *CellBuffer) Size() (int, int) {
|
||||
return cb.w, cb.h
|
||||
}
|
||||
|
||||
// Invalidate marks all characters within the buffer as dirty.
|
||||
func (cb *CellBuffer) Invalidate() {
|
||||
for i := range cb.cells {
|
||||
cb.cells[i].lastMain = rune(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Dirty checks if a character at the given location needs to be
|
||||
// refreshed on the physical display. This returns true if the cell
|
||||
// content is different since the last time it was marked clean.
|
||||
func (cb *CellBuffer) Dirty(x, y int) bool {
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
if c.lock {
|
||||
return false
|
||||
}
|
||||
if c.lastMain == rune(0) {
|
||||
return true
|
||||
}
|
||||
if c.lastMain != c.currMain {
|
||||
return true
|
||||
}
|
||||
if c.lastStyle != c.currStyle {
|
||||
return true
|
||||
}
|
||||
if len(c.lastComb) != len(c.currComb) {
|
||||
return true
|
||||
}
|
||||
for i := range c.lastComb {
|
||||
if c.lastComb[i] != c.currComb[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetDirty is normally used to indicate that a cell has
|
||||
// been displayed (in which case dirty is false), or to manually
|
||||
// force a cell to be marked dirty.
|
||||
func (cb *CellBuffer) SetDirty(x, y int, dirty bool) {
|
||||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
if dirty {
|
||||
c.lastMain = rune(0)
|
||||
} else {
|
||||
if c.currMain == rune(0) {
|
||||
c.currMain = ' '
|
||||
}
|
||||
c.lastMain = c.currMain
|
||||
c.lastComb = c.currComb
|
||||
c.lastStyle = c.currStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LockCell locks a cell from being drawn, effectively marking it "clean" until
|
||||
// the lock is removed. This can be used to prevent tcell from drawing a given
|
||||
// cell, even if the underlying content has changed. For example, when drawing a
|
||||
// sixel graphic directly to a TTY screen an implementer must lock the region
|
||||
// underneath the graphic to prevent tcell from drawing on top of the graphic.
|
||||
func (cb *CellBuffer) LockCell(x, y int) {
|
||||
if x < 0 || y < 0 {
|
||||
return
|
||||
}
|
||||
if x >= cb.w || y >= cb.h {
|
||||
return
|
||||
}
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
c.lock = true
|
||||
}
|
||||
|
||||
// UnlockCell removes a lock from the cell and marks it as dirty
|
||||
func (cb *CellBuffer) UnlockCell(x, y int) {
|
||||
if x < 0 || y < 0 {
|
||||
return
|
||||
}
|
||||
if x >= cb.w || y >= cb.h {
|
||||
return
|
||||
}
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
c.lock = false
|
||||
cb.SetDirty(x, y, true)
|
||||
}
|
||||
|
||||
// Resize is used to resize the cells array, with different dimensions,
|
||||
// while preserving the original contents. The cells will be invalidated
|
||||
// so that they can be redrawn.
|
||||
func (cb *CellBuffer) Resize(w, h int) {
|
||||
if cb.h == h && cb.w == w {
|
||||
return
|
||||
}
|
||||
|
||||
newc := make([]cell, w*h)
|
||||
for y := 0; y < h && y < cb.h; y++ {
|
||||
for x := 0; x < w && x < cb.w; x++ {
|
||||
oc := &cb.cells[(y*cb.w)+x]
|
||||
nc := &newc[(y*w)+x]
|
||||
nc.currMain = oc.currMain
|
||||
nc.currComb = oc.currComb
|
||||
nc.currStyle = oc.currStyle
|
||||
nc.width = oc.width
|
||||
nc.lastMain = rune(0)
|
||||
}
|
||||
}
|
||||
cb.cells = newc
|
||||
cb.h = h
|
||||
cb.w = w
|
||||
}
|
||||
|
||||
// Fill fills the entire cell buffer array with the specified character
|
||||
// and style. Normally choose ' ' to clear the screen. This API doesn't
|
||||
// support combining characters, or characters with a width larger than one.
|
||||
// If either the foreground or background are ColorNone, then the respective
|
||||
// color is unchanged.
|
||||
func (cb *CellBuffer) Fill(r rune, style Style) {
|
||||
for i := range cb.cells {
|
||||
c := &cb.cells[i]
|
||||
c.currMain = r
|
||||
c.currComb = nil
|
||||
cs := style
|
||||
if cs.fg == ColorNone {
|
||||
cs.fg = c.currStyle.fg
|
||||
}
|
||||
if cs.bg == ColorNone {
|
||||
cs.bg = c.currStyle.bg
|
||||
}
|
||||
c.currStyle = cs
|
||||
c.width = 1
|
||||
}
|
||||
}
|
||||
|
||||
var runeConfig *runewidth.Condition
|
||||
|
||||
func init() {
|
||||
// The defaults for the runewidth package are poorly chosen for terminal
|
||||
// applications. We however will honor the setting in the environment if
|
||||
// it is set.
|
||||
if os.Getenv("RUNEWIDTH_EASTASIAN") == "" {
|
||||
runewidth.DefaultCondition.EastAsianWidth = false
|
||||
}
|
||||
}
|
||||
22
vendor/github.com/gdamore/tcell/v2/charset_stub.go
generated
vendored
Normal file
22
vendor/github.com/gdamore/tcell/v2/charset_stub.go
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build plan9 || nacl
|
||||
// +build plan9 nacl
|
||||
|
||||
// Copyright 2015 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
func getCharset() string {
|
||||
return ""
|
||||
}
|
||||
50
vendor/github.com/gdamore/tcell/v2/charset_unix.go
generated
vendored
Normal file
50
vendor/github.com/gdamore/tcell/v2/charset_unix.go
generated
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build !windows && !nacl && !plan9
|
||||
// +build !windows,!nacl,!plan9
|
||||
|
||||
// Copyright 2016 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getCharset() string {
|
||||
// Determine the character set. This can help us later.
|
||||
// Per POSIX, we search for LC_ALL first, then LC_CTYPE, and
|
||||
// finally LANG. First one set wins.
|
||||
locale := ""
|
||||
if locale = os.Getenv("LC_ALL"); locale == "" {
|
||||
if locale = os.Getenv("LC_CTYPE"); locale == "" {
|
||||
locale = os.Getenv("LANG")
|
||||
}
|
||||
}
|
||||
if locale == "POSIX" || locale == "C" {
|
||||
return "US-ASCII"
|
||||
}
|
||||
if i := strings.IndexRune(locale, '@'); i >= 0 {
|
||||
locale = locale[:i]
|
||||
}
|
||||
if i := strings.IndexRune(locale, '.'); i >= 0 {
|
||||
locale = locale[i+1:]
|
||||
} else {
|
||||
// Default assumption, and on Linux we can see LC_ALL
|
||||
// without a character set, which we assume implies UTF-8.
|
||||
return "UTF-8"
|
||||
}
|
||||
// XXX: add support for aliases
|
||||
return locale
|
||||
}
|
||||
22
vendor/github.com/gdamore/tcell/v2/charset_windows.go
generated
vendored
Normal file
22
vendor/github.com/gdamore/tcell/v2/charset_windows.go
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Copyright 2015 The TCell Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use file except in compliance with the License.
|
||||
// You may obtain a copy of the license at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tcell
|
||||
|
||||
func getCharset() string {
|
||||
return "UTF-16"
|
||||
}
|
||||
1128
vendor/github.com/gdamore/tcell/v2/color.go
generated
vendored
Normal file
1128
vendor/github.com/gdamore/tcell/v2/color.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user