Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f55505444 | |||
| e0e7b64c69 | |||
| 4181cb1fbd | |||
| 120ffc6a5a | |||
| b20ad35485 | |||
| f258f8baeb | |||
| 6388daba56 | |||
| f6c3f2b460 | |||
| 156e655571 | |||
| b57e1ba304 | |||
| 19fba62f1b | |||
| b4ff4334cc | |||
| 5d9b00c8f2 | |||
| debf351c48 | |||
| d87d657275 | |||
| 1795eb64d1 | |||
| 355f0f918f | |||
| 5d3c86119e | |||
| 8c602e3db0 | |||
| 64aeac972a | |||
| 97a57f5dc8 | |||
| adfe126758 | |||
| 1d193c84d7 | |||
| 1d627c74b1 | |||
| 7c6a355458 | |||
| c0ef26b660 | |||
| cb38f95b79 | |||
| 196d87bc29 | |||
| beb1100d86 | |||
| 410b1ee743 | |||
| b5d39aeee4 | |||
| 5fb9a8f231 | |||
| 27da24f575 | |||
| 0fb3469dbd |
@@ -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,
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: CI
|
||||
|
||||
run-name: "Test on master branch"
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
68
.github/workflows/integration-tests.yml
vendored
68
.github/workflows/integration-tests.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Integration Tests
|
||||
|
||||
run-name: "Integration Tests"
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
@@ -11,6 +11,21 @@ jobs:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: relspec
|
||||
POSTGRES_PASSWORD: relspec_test_password
|
||||
POSTGRES_DB: relspec_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,41 +46,24 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Start PostgreSQL container
|
||||
- name: Install PostgreSQL client
|
||||
run: |
|
||||
docker run -d \
|
||||
--name relspec-test-postgres \
|
||||
--network host \
|
||||
-e POSTGRES_USER=relspec \
|
||||
-e POSTGRES_PASSWORD=relspec_test_password \
|
||||
-e POSTGRES_DB=relspec_test \
|
||||
postgres:16-alpine
|
||||
|
||||
- name: Wait for PostgreSQL to be ready
|
||||
run: |
|
||||
echo "Waiting for PostgreSQL to start..."
|
||||
for i in {1..30}; do
|
||||
if docker exec relspec-test-postgres pg_isready -U relspec -d relspec_test > /dev/null 2>&1; then
|
||||
echo "PostgreSQL is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
sleep 2
|
||||
|
||||
- name: Copy init script into container
|
||||
run: |
|
||||
docker cp tests/postgres/init.sql relspec-test-postgres:/tmp/init.sql
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
|
||||
- name: Initialize test database
|
||||
env:
|
||||
PGPASSWORD: relspec_test_password
|
||||
run: |
|
||||
docker exec relspec-test-postgres psql -U relspec -d relspec_test -f /tmp/init.sql
|
||||
# Services are accessible via hostname matching the service name
|
||||
psql -h postgres -U relspec -d relspec_test -f tests/postgres/init.sql
|
||||
|
||||
- name: Verify database setup
|
||||
env:
|
||||
PGPASSWORD: relspec_test_password
|
||||
run: |
|
||||
echo "Verifying database initialization..."
|
||||
docker exec relspec-test-postgres psql -U relspec -d relspec_test -c "
|
||||
psql -h postgres -U relspec -d relspec_test -c "
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') AND nspname NOT LIKE 'pg_%') as schemas,
|
||||
(SELECT COUNT(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) as tables,
|
||||
@@ -75,17 +73,5 @@ jobs:
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
RELSPEC_TEST_PG_CONN: postgres://relspec:relspec_test_password@localhost:5432/relspec_test
|
||||
RELSPEC_TEST_PG_CONN: postgres://relspec:relspec_test_password@postgres:5432/relspec_test
|
||||
run: make test-integration
|
||||
|
||||
- name: Stop PostgreSQL container
|
||||
if: always()
|
||||
run: |
|
||||
docker stop relspec-test-postgres || true
|
||||
docker rm relspec-test-postgres || true
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "Integration tests completed."
|
||||
echo "PostgreSQL container has been cleaned up."
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Release
|
||||
|
||||
run-name: "Making Release"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
|
||||
71
Makefile
71
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration release release-version
|
||||
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=relspec
|
||||
@@ -14,9 +14,29 @@ GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
|
||||
# Auto-detect container runtime (Docker or Podman)
|
||||
CONTAINER_RUNTIME := $(shell \
|
||||
if command -v podman > /dev/null 2>&1; then \
|
||||
echo "podman"; \
|
||||
elif command -v docker > /dev/null 2>&1; then \
|
||||
echo "docker"; \
|
||||
else \
|
||||
echo "none"; \
|
||||
fi)
|
||||
|
||||
# Detect compose command
|
||||
COMPOSE_CMD := $(shell \
|
||||
if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \
|
||||
echo "podman-compose"; \
|
||||
elif command -v docker-compose > /dev/null 2>&1; then \
|
||||
echo "docker-compose"; \
|
||||
else \
|
||||
echo "docker compose"; \
|
||||
fi)
|
||||
|
||||
all: lint test build ## Run linting, tests, and build
|
||||
|
||||
build: ## Build the binary
|
||||
build: deps ## Build the binary
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/relspec
|
||||
@@ -32,7 +52,7 @@ test-integration: ## Run integration tests (requires RELSPEC_TEST_PG_CONN enviro
|
||||
@echo "Running integration tests..."
|
||||
@if [ -z "$$RELSPEC_TEST_PG_CONN" ]; then \
|
||||
echo "Error: RELSPEC_TEST_PG_CONN environment variable is not set"; \
|
||||
echo "Example: export RELSPEC_TEST_PG_CONN='postgres://relspec:relspec_test_password@localhost:5432/relspec_test'"; \
|
||||
echo "Example: export RELSPEC_TEST_PG_CONN='postgres://relspec:relspec_test_password@localhost:5439/relspec_test'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Running PostgreSQL reader tests..."
|
||||
@@ -81,34 +101,53 @@ deps: ## Download dependencies
|
||||
$(GOMOD) tidy
|
||||
@echo "Dependencies updated"
|
||||
|
||||
start: docker-up ## Alias for docker-up (start PostgreSQL test database)
|
||||
|
||||
stop: docker-down ## Alias for docker-down (stop PostgreSQL test database)
|
||||
|
||||
docker-up: ## Start PostgreSQL test database
|
||||
@echo "Starting PostgreSQL test database..."
|
||||
@if command -v docker-compose > /dev/null 2>&1; then \
|
||||
docker-compose up -d postgres; \
|
||||
@echo "Starting PostgreSQL test database (using $(CONTAINER_RUNTIME))..."
|
||||
@if [ "$(CONTAINER_RUNTIME)" = "none" ]; then \
|
||||
echo "Error: Neither Docker nor Podman is installed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \
|
||||
podman run -d --name relspec-test-postgres \
|
||||
-e POSTGRES_USER=relspec \
|
||||
-e POSTGRES_PASSWORD=relspec_test_password \
|
||||
-e POSTGRES_DB=relspec_test \
|
||||
-p 5439:5432 \
|
||||
-v ./tests/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:Z \
|
||||
postgres:16-alpine 2>/dev/null || echo "Container already running"; \
|
||||
else \
|
||||
docker compose up -d postgres; \
|
||||
$(COMPOSE_CMD) up -d postgres; \
|
||||
fi
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@sleep 3
|
||||
@echo "PostgreSQL is running on port 5433"
|
||||
@echo "Connection: postgres://relspec:relspec_test_password@localhost:5433/relspec_test"
|
||||
@echo "PostgreSQL is running on port 5439"
|
||||
@echo "Connection: postgres://relspec:relspec_test_password@localhost:5439/relspec_test"
|
||||
|
||||
docker-down: ## Stop PostgreSQL test database
|
||||
@echo "Stopping PostgreSQL test database..."
|
||||
@if command -v docker-compose > /dev/null 2>&1; then \
|
||||
docker-compose down; \
|
||||
@echo "Stopping PostgreSQL test database (using $(CONTAINER_RUNTIME))..."
|
||||
@if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \
|
||||
podman stop relspec-test-postgres 2>/dev/null || true; \
|
||||
podman rm relspec-test-postgres 2>/dev/null || true; \
|
||||
else \
|
||||
docker compose down; \
|
||||
$(COMPOSE_CMD) down; \
|
||||
fi
|
||||
@echo "PostgreSQL stopped"
|
||||
|
||||
docker-test: ## Run PostgreSQL integration tests with Docker
|
||||
@./tests/postgres/run_tests.sh
|
||||
docker-test: ## Run PostgreSQL integration tests with Docker/Podman
|
||||
@if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \
|
||||
./tests/postgres/run_tests_podman.sh; \
|
||||
else \
|
||||
./tests/postgres/run_tests.sh; \
|
||||
fi
|
||||
|
||||
docker-test-integration: docker-up ## Start DB and run integration tests
|
||||
@echo "Running integration tests..."
|
||||
@sleep 2
|
||||
@RELSPEC_TEST_PG_CONN="postgres://relspec:relspec_test_password@localhost:5433/relspec_test" \
|
||||
@RELSPEC_TEST_PG_CONN="postgres://relspec:relspec_test_password@localhost:5439/relspec_test" \
|
||||
$(GOTEST) -v ./pkg/readers/pgsql/ -count=1 || (make docker-down && exit 1)
|
||||
@make docker-down
|
||||
|
||||
|
||||
152
README.md
152
README.md
@@ -1,16 +1,24 @@
|
||||
# RelSpec
|
||||
|
||||
[](https://git.warky.dev/wdevs/relspecgo/releases/latest)
|
||||
[](https://git.warky.dev/wdevs/relspecgo/actions/workflows/ci.yml)
|
||||
[](https://git.warky.dev/wdevs/relspecgo/actions/workflows/integration-tests.yml)
|
||||
[](https://go.dev/dl/)
|
||||
[](LICENSE)
|
||||
|
||||
> Database Relations Specification Tool for Go
|
||||
|
||||
RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||
|
||||
## Overview
|
||||
|
||||
RelSpec provides bidirectional conversion and comparison between various database specification formats, allowing you to:
|
||||
RelSpec provides bidirectional conversion, comparison, and validation of database specification formats, allowing you to:
|
||||
- Inspect live databases and extract their structure
|
||||
- Convert between different ORM models (GORM, Bun , etc.)
|
||||
- Validate schemas against configurable rules and naming conventions
|
||||
- Convert between different ORM models (GORM, Bun, etc.)
|
||||
- Transform legacy schema definitions (Clarion DCTX, XML, JSON, etc.)
|
||||
- Generate standardized specification files (JSON, YAML, etc.)
|
||||
- Compare database schemas and track changes
|
||||
|
||||

|
||||
|
||||
@@ -60,10 +68,46 @@ RelSpec can write database schemas to multiple formats:
|
||||
- [JSON](pkg/writers/json/README.md) - RelSpec canonical JSON format
|
||||
- [YAML](pkg/writers/yaml/README.md) - RelSpec canonical YAML format
|
||||
|
||||
### Inspector (Schema Validation)
|
||||
|
||||
RelSpec includes a powerful schema validation and linting tool:
|
||||
|
||||
- [Inspector](pkg/inspector/README.md) - Validate database schemas against configurable rules
|
||||
- Enforce naming conventions (snake_case, camelCase, custom patterns)
|
||||
- Check primary key and foreign key standards
|
||||
- Detect missing indexes on foreign keys
|
||||
- Prevent use of SQL reserved keywords
|
||||
- Ensure schema integrity (missing PKs, orphaned FKs, circular dependencies)
|
||||
- Support for custom validation rules
|
||||
- Multiple output formats (Markdown with colors, JSON)
|
||||
- CI/CD integration ready
|
||||
|
||||
## 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
|
||||
@@ -74,30 +118,114 @@ go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Schema Editor
|
||||
|
||||
```bash
|
||||
# Inspect database and generate GORM models
|
||||
relspec --input db --conn "postgres://..." --output gorm --out-file models.go
|
||||
# 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
|
||||
# Convert PostgreSQL database to GORM models
|
||||
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to gorm --to-path models/ --package models
|
||||
|
||||
# Convert GORM models to Bun
|
||||
relspec --input gorm --in-file existing.go --output bun --out-file bun_models.go
|
||||
relspec convert --from gorm --from-path models.go \
|
||||
--to bun --to-path bun_models.go --package models
|
||||
|
||||
# Export database schema to JSON
|
||||
relspec --input db --conn "mysql://..." --output json --out-file schema.json
|
||||
relspec convert --from pgsql --from-conn "postgres://..." \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# Convert Clarion DCTX to YAML
|
||||
relspec --input dctx --in-file legacy.dctx --output yaml --out-file schema.yaml
|
||||
# Convert DBML to PostgreSQL SQL
|
||||
relspec convert --from dbml --from-path schema.dbml \
|
||||
--to pgsql --to-path schema.sql
|
||||
```
|
||||
|
||||
### Schema Validation
|
||||
|
||||
```bash
|
||||
# Validate a PostgreSQL database with default rules
|
||||
relspec inspect --from pgsql --from-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Validate DBML file with custom rules
|
||||
relspec inspect --from dbml --from-path schema.dbml --rules .relspec-rules.yaml
|
||||
|
||||
# Generate JSON validation report
|
||||
relspec inspect --from json --from-path db.json \
|
||||
--output-format json --output report.json
|
||||
|
||||
# Validate specific schema only
|
||||
relspec inspect --from pgsql --from-conn "..." --schema public
|
||||
```
|
||||
|
||||
### Schema Comparison
|
||||
|
||||
```bash
|
||||
# Compare two database schemas
|
||||
relspec diff --from pgsql --from-conn "postgres://localhost/db1" \
|
||||
--to pgsql --to-conn "postgres://localhost/db2"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
relspecgo/
|
||||
├── cmd/ # CLI application
|
||||
├── cmd/
|
||||
│ └── relspec/ # CLI application (convert, inspect, diff, scripts)
|
||||
├── pkg/
|
||||
│ ├── readers/ # Input format readers
|
||||
│ ├── writers/ # Output format writers
|
||||
│ ├── readers/ # Input format readers (DBML, GORM, PostgreSQL, etc.)
|
||||
│ ├── writers/ # Output format writers (GORM, Bun, SQL, etc.)
|
||||
│ ├── inspector/ # Schema validation and linting
|
||||
│ ├── diff/ # Schema comparison
|
||||
│ ├── models/ # Internal data models
|
||||
│ └── transform/ # Transformation logic
|
||||
│ ├── transform/ # Transformation logic
|
||||
│ └── pgsql/ # PostgreSQL utilities (keywords, data types)
|
||||
├── examples/ # Usage examples
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
12
TODO.md
12
TODO.md
@@ -2,6 +2,7 @@
|
||||
|
||||
|
||||
## Input Readers / Writers
|
||||
|
||||
- [✔️] **Database Inspector**
|
||||
- [✔️] PostgreSQL driver
|
||||
- [ ] MySQL driver
|
||||
@@ -21,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
|
||||
}
|
||||
321
cmd/relspec/inspect.go
Normal file
321
cmd/relspec/inspect.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/inspector"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
inspectSourceType string
|
||||
inspectSourcePath string
|
||||
inspectSourceConn string
|
||||
inspectRulesPath string
|
||||
inspectOutputFormat string
|
||||
inspectOutputPath string
|
||||
inspectSchemaFilter string
|
||||
)
|
||||
|
||||
var inspectCmd = &cobra.Command{
|
||||
Use: "inspect",
|
||||
Short: "Inspect and validate database schemas against rules",
|
||||
Long: `Inspect database schemas from various formats and validate against configurable rules.
|
||||
|
||||
Supports reading from multiple sources (live databases, DBML, DCTX, DrawDB,
|
||||
JSON, YAML, etc.) and generates validation reports.
|
||||
|
||||
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:
|
||||
- markdown: Human-readable markdown report (default, with ANSI colors for terminal)
|
||||
- json: JSON report for tooling integration
|
||||
|
||||
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:
|
||||
# Inspect a PostgreSQL database with default rules
|
||||
relspec inspect --from pgsql --from-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Inspect a DBML file with custom rules
|
||||
relspec inspect --from dbml --from-path schema.dbml --rules my-rules.yaml
|
||||
|
||||
# Inspect and output JSON report to file
|
||||
relspec inspect --from json --from-path db.json \
|
||||
--output-format json --output report.json
|
||||
|
||||
# Inspect specific schema only
|
||||
relspec inspect --from pgsql --from-conn "..." --schema public`,
|
||||
RunE: runInspect,
|
||||
}
|
||||
|
||||
func init() {
|
||||
inspectCmd.Flags().StringVar(&inspectSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
inspectCmd.Flags().StringVar(&inspectSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
inspectCmd.Flags().StringVar(&inspectSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
inspectCmd.Flags().StringVar(&inspectRulesPath, "rules", ".relspec-rules.yaml", "Path to rules configuration file (uses defaults if not found)")
|
||||
inspectCmd.Flags().StringVar(&inspectOutputFormat, "output-format", "markdown", "Output format (markdown, json)")
|
||||
inspectCmd.Flags().StringVar(&inspectOutputPath, "output", "", "Output file path (default: stdout)")
|
||||
inspectCmd.Flags().StringVar(&inspectSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
|
||||
err := inspectCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runInspect(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Inspector ===\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", inspectSourceType)
|
||||
if inspectSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", inspectSourcePath)
|
||||
}
|
||||
if inspectSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(inspectSourceConn))
|
||||
}
|
||||
|
||||
db, err := readDatabaseForInspect(inspectSourceType, inspectSourcePath, inspectSourceConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
// Apply schema filter if specified
|
||||
if inspectSchemaFilter != "" {
|
||||
db = filterDatabaseBySchema(db, inspectSchemaFilter)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Load rules configuration
|
||||
fmt.Fprintf(os.Stderr, "[2/3] Loading validation rules...\n")
|
||||
fmt.Fprintf(os.Stderr, " Rules: %s\n", inspectRulesPath)
|
||||
|
||||
config, err := inspector.LoadConfig(inspectRulesPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load rules config: %w", err)
|
||||
}
|
||||
|
||||
enabledCount := 0
|
||||
for _, rule := range config.Rules {
|
||||
if rule.IsEnabled() {
|
||||
enabledCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Loaded %d rule(s) (%d enabled)\n\n", len(config.Rules), enabledCount)
|
||||
|
||||
// Run inspection
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Running validation...\n")
|
||||
|
||||
insp := inspector.NewInspector(db, config)
|
||||
report, err := insp.Inspect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspection failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Inspection complete\n")
|
||||
fmt.Fprintf(os.Stderr, " Errors: %d\n", report.Summary.ErrorCount)
|
||||
fmt.Fprintf(os.Stderr, " Warnings: %d\n\n", report.Summary.WarningCount)
|
||||
|
||||
// Format and output report
|
||||
var formattedReport string
|
||||
switch strings.ToLower(inspectOutputFormat) {
|
||||
case "json":
|
||||
formatter := inspector.NewJSONFormatter()
|
||||
formattedReport, err = formatter.Format(report)
|
||||
case "markdown", "md":
|
||||
// Determine output writer for terminal detection
|
||||
var output *os.File
|
||||
if inspectOutputPath != "" {
|
||||
output, err = os.Create(inspectOutputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer output.Close()
|
||||
} else {
|
||||
output = os.Stdout
|
||||
}
|
||||
|
||||
formatter := inspector.NewMarkdownFormatter(output)
|
||||
formattedReport, err = formatter.Format(report)
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", inspectOutputFormat)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format report: %w", err)
|
||||
}
|
||||
|
||||
// Write output
|
||||
if inspectOutputPath != "" {
|
||||
err = os.WriteFile(inspectOutputPath, []byte(formattedReport), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output file: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Report written to: %s\n", inspectOutputPath)
|
||||
} else {
|
||||
fmt.Println(formattedReport)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Inspection Complete ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Completed at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Exit with appropriate code
|
||||
if report.HasErrors() {
|
||||
return fmt.Errorf("inspection found %d error(s)", report.Summary.ErrorCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readDatabaseForInspect(dbType, filePath, connString string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML format")
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DCTX format")
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DrawDB format")
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for JSON format")
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "yaml", "yml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for YAML format")
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GORM format")
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Bun format")
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle format")
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma format")
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM format")
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for PostgreSQL format")
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
}
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func filterDatabaseBySchema(db *models.Database, schemaName string) *models.Database {
|
||||
filtered := &models.Database{
|
||||
Name: db.Name,
|
||||
Description: db.Description,
|
||||
DatabaseType: db.DatabaseType,
|
||||
DatabaseVersion: db.DatabaseVersion,
|
||||
SourceFormat: db.SourceFormat,
|
||||
Schemas: []*models.Schema{},
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name == schemaName {
|
||||
filtered.Schemas = append(filtered.Schemas, schema)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
433
cmd/relspec/merge.go
Normal file
433
cmd/relspec/merge.go
Normal file
@@ -0,0 +1,433 @@
|
||||
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
|
||||
)
|
||||
|
||||
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 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")
|
||||
}
|
||||
|
||||
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, "", 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":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
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
|
||||
}
|
||||
@@ -18,4 +18,10 @@ JSON, YAML, SQL, etc.).`,
|
||||
func init() {
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
}
|
||||
|
||||
263
cmd/relspec/scripts.go
Normal file
263
cmd/relspec/scripts.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
)
|
||||
|
||||
var (
|
||||
scriptsDir string
|
||||
scriptsConn string
|
||||
scriptsSchemaName string
|
||||
scriptsDBName string
|
||||
)
|
||||
|
||||
var scriptsCmd = &cobra.Command{
|
||||
Use: "scripts",
|
||||
Short: "Manage and execute SQL migration scripts",
|
||||
Long: `Manage and execute SQL migration scripts from a directory.
|
||||
|
||||
Scripts must follow the naming pattern (both separators supported):
|
||||
{priority}_{sequence}_{name}.sql or .pgsql
|
||||
{priority}-{sequence}-{name}.sql or .pgsql
|
||||
|
||||
Example filenames (underscore format):
|
||||
1_001_create_users.sql # Priority 1, Sequence 1
|
||||
1_002_create_posts.sql # Priority 1, Sequence 2
|
||||
2_001_add_indexes.pgsql # Priority 2, Sequence 1
|
||||
|
||||
Example filenames (hyphen format):
|
||||
1-001-create-users.sql # Priority 1, Sequence 1
|
||||
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).`,
|
||||
}
|
||||
|
||||
var scriptsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
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).
|
||||
|
||||
Example:
|
||||
relspec scripts list --dir ./migrations`,
|
||||
RunE: runScriptsList,
|
||||
}
|
||||
|
||||
var scriptsExecuteCmd = &cobra.Command{
|
||||
Use: "execute",
|
||||
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.
|
||||
|
||||
The directory is scanned recursively for files matching the patterns:
|
||||
{priority}_{sequence}_{name}.sql or .pgsql (underscore format)
|
||||
{priority}-{sequence}-{name}.sql or .pgsql (hyphen format)
|
||||
|
||||
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
|
||||
|
||||
Examples:
|
||||
# Execute migration scripts
|
||||
relspec scripts execute --dir ./migrations \
|
||||
--conn "postgres://user:pass@localhost:5432/mydb"
|
||||
|
||||
# Execute with custom schema name
|
||||
relspec scripts execute --dir ./migrations \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--schema public
|
||||
|
||||
# Execute with SSL disabled
|
||||
relspec scripts execute --dir ./sql \
|
||||
--conn "postgres://user:pass@localhost/db?sslmode=disable"`,
|
||||
RunE: runScriptsExecute,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List command flags
|
||||
scriptsListCmd.Flags().StringVar(&scriptsDir, "dir", "", "Directory containing SQL scripts (required)")
|
||||
scriptsListCmd.Flags().StringVar(&scriptsSchemaName, "schema", "public", "Schema name (optional, default: public)")
|
||||
scriptsListCmd.Flags().StringVar(&scriptsDBName, "database", "database", "Database name (optional, default: database)")
|
||||
err := scriptsListCmd.MarkFlagRequired("dir")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
|
||||
}
|
||||
|
||||
// Execute command flags
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsDir, "dir", "", "Directory containing SQL scripts (required)")
|
||||
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)")
|
||||
|
||||
err = scriptsExecuteCmd.MarkFlagRequired("dir")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
|
||||
}
|
||||
err = scriptsExecuteCmd.MarkFlagRequired("conn")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking conn flag as required: %v\n", err)
|
||||
}
|
||||
|
||||
// Add subcommands to scripts command
|
||||
scriptsCmd.AddCommand(scriptsListCmd)
|
||||
scriptsCmd.AddCommand(scriptsExecuteCmd)
|
||||
}
|
||||
|
||||
func runScriptsList(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== SQL Scripts List ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Directory: %s\n\n", scriptsDir)
|
||||
|
||||
// Read scripts from directory
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: scriptsDir,
|
||||
Metadata: map[string]any{
|
||||
"schema_name": scriptsSchemaName,
|
||||
"database_name": scriptsDBName,
|
||||
},
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read scripts: %w", err)
|
||||
}
|
||||
|
||||
if len(db.Schemas) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No schemas found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if len(schema.Scripts) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No SQL scripts found matching pattern {priority}_{sequence}_{name}.sql\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort scripts by Priority then Sequence
|
||||
sortedScripts := make([]*struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
sqlLines int
|
||||
}, len(schema.Scripts))
|
||||
|
||||
for i, script := range schema.Scripts {
|
||||
// Count non-empty lines in SQL
|
||||
sqlLines := 0
|
||||
for _, line := range []byte(script.SQL) {
|
||||
if line == '\n' {
|
||||
sqlLines++
|
||||
}
|
||||
}
|
||||
if len(script.SQL) > 0 {
|
||||
sqlLines++ // Count last line if no trailing newline
|
||||
}
|
||||
|
||||
sortedScripts[i] = &struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
sqlLines int
|
||||
}{
|
||||
name: script.Name,
|
||||
priority: script.Priority,
|
||||
sequence: script.Sequence,
|
||||
sqlLines: sqlLines,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Found %d script(s) in execution order:\n\n", len(sortedScripts))
|
||||
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-30s %s\n", "No.", "Priority", "Sequence", "Name", "Lines")
|
||||
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-30s %s\n", "----", "--------", "--------", "------------------------------", "-----")
|
||||
|
||||
for i, script := range sortedScripts {
|
||||
fmt.Fprintf(os.Stderr, "%-4d %-10d %-8d %-30s %d\n",
|
||||
i+1,
|
||||
script.priority,
|
||||
script.sequence,
|
||||
script.name,
|
||||
script.sqlLines,
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runScriptsExecute(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== SQL Scripts Execution ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Directory: %s\n", scriptsDir)
|
||||
fmt.Fprintf(os.Stderr, "Database: %s\n\n", maskPassword(scriptsConn))
|
||||
|
||||
// Step 1: Read scripts from directory
|
||||
fmt.Fprintf(os.Stderr, "[1/2] Reading SQL scripts...\n")
|
||||
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: scriptsDir,
|
||||
Metadata: map[string]any{
|
||||
"schema_name": scriptsSchemaName,
|
||||
"database_name": scriptsDBName,
|
||||
},
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read scripts: %w", err)
|
||||
}
|
||||
|
||||
if len(db.Schemas) == 0 {
|
||||
return fmt.Errorf("no schemas found")
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if len(schema.Scripts) == 0 {
|
||||
fmt.Fprintf(os.Stderr, " No scripts found. Nothing to execute.\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": scriptsConn,
|
||||
},
|
||||
})
|
||||
|
||||
if err := writer.WriteSchema(schema); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
return fmt.Errorf("execution failed: %w", err)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
}
|
||||
167
cmd/relspec/templ.go
Normal file
167
cmd/relspec/templ.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
|
||||
)
|
||||
|
||||
var (
|
||||
templSourceType string
|
||||
templSourcePath string
|
||||
templSourceConn string
|
||||
templTemplatePath string
|
||||
templOutputPath string
|
||||
templSchemaFilter string
|
||||
templMode string
|
||||
templFilenamePattern string
|
||||
)
|
||||
|
||||
var templCmd = &cobra.Command{
|
||||
Use: "templ",
|
||||
Short: "Apply custom templates to database schemas",
|
||||
Long: `Apply custom Go text templates to database schemas with flexible execution modes.
|
||||
|
||||
The templ command allows you to transform database schemas using custom Go text
|
||||
templates. It supports multiple execution modes for different use cases:
|
||||
|
||||
Execution Modes:
|
||||
database Execute template once for entire database (single output file)
|
||||
schema Execute template once per schema (one file per schema)
|
||||
script Execute template once per script (one file per script)
|
||||
table Execute template once per table (one file per table)
|
||||
|
||||
Supported Input Formats:
|
||||
dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql
|
||||
|
||||
Template Functions:
|
||||
String utilities: toUpper, toLower, toCamelCase, toPascalCase, toSnakeCase, toKebabCase,
|
||||
pluralize, singularize, title, trim, split, join, replace
|
||||
|
||||
Type conversion: sqlToGo, sqlToTypeScript, sqlToJava, sqlToPython, sqlToRust,
|
||||
sqlToCSharp, sqlToPhp
|
||||
|
||||
Filtering: filterTables, filterColumns, filterPrimaryKeys, filterForeignKeys,
|
||||
filterNullable, filterNotNull, filterColumnsByType
|
||||
|
||||
Formatting: toJSON, toJSONPretty, toYAML, indent, escape, comment
|
||||
|
||||
Loop helpers: enumerate, batch, reverse, first, last, skip, take, concat,
|
||||
unique, sortBy, groupBy
|
||||
|
||||
Safe access: get, getOr, getPath, has, keys, values, merge, pick, omit,
|
||||
sliceContains, indexOf, pluck
|
||||
|
||||
Examples:
|
||||
# Generate documentation from PostgreSQL database
|
||||
relspec templ --from pgsql --from-conn "postgres://user:pass@localhost/db" \
|
||||
--template docs.tmpl --output schema-docs.md
|
||||
|
||||
# Generate one TypeScript model file per table
|
||||
relspec templ --from dbml --from-path schema.dbml \
|
||||
--template ts-model.tmpl --mode table \
|
||||
--output ./models/ \
|
||||
--filename-pattern "{{.Name | toCamelCase}}.ts"
|
||||
|
||||
# Generate schema documentation files
|
||||
relspec templ --from json --from-path db.json \
|
||||
--template schema.tmpl --mode schema \
|
||||
--output ./docs/ \
|
||||
--filename-pattern "{{.Name}}_schema.md"`,
|
||||
RunE: runTempl,
|
||||
}
|
||||
|
||||
func init() {
|
||||
templCmd.Flags().StringVar(&templSourceType, "from", "", "Source format (dbml, pgsql, json, etc.)")
|
||||
templCmd.Flags().StringVar(&templSourcePath, "from-path", "", "Source file path (for file-based sources)")
|
||||
templCmd.Flags().StringVar(&templSourceConn, "from-conn", "", "Source connection string (for database sources)")
|
||||
templCmd.Flags().StringVar(&templTemplatePath, "template", "", "Template file path (required)")
|
||||
templCmd.Flags().StringVar(&templOutputPath, "output", "", "Output path (file or directory, empty for stdout)")
|
||||
templCmd.Flags().StringVar(&templSchemaFilter, "schema", "", "Filter to specific schema")
|
||||
templCmd.Flags().StringVar(&templMode, "mode", "database", "Execution mode: database, schema, script, or table")
|
||||
templCmd.Flags().StringVar(&templFilenamePattern, "filename-pattern", "{{.Name}}.txt", "Filename pattern for multi-output modes")
|
||||
|
||||
_ = templCmd.MarkFlagRequired("from")
|
||||
_ = templCmd.MarkFlagRequired("template")
|
||||
}
|
||||
|
||||
func runTempl(cmd *cobra.Command, args []string) error {
|
||||
// Print header
|
||||
fmt.Fprintf(os.Stderr, "=== RelSpec Template Execution ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Read database using the same function as convert
|
||||
fmt.Fprintf(os.Stderr, "Reading from %s...\n", templSourceType)
|
||||
db, err := readDatabaseForConvert(templSourceType, templSourcePath, templSourceConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
|
||||
// Print database stats
|
||||
schemaCount := len(db.Schemas)
|
||||
tableCount := 0
|
||||
for _, schema := range db.Schemas {
|
||||
tableCount += len(schema.Tables)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully read database: %s\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Schemas: %d\n", schemaCount)
|
||||
fmt.Fprintf(os.Stderr, " Tables: %d\n\n", tableCount)
|
||||
|
||||
// Apply schema filter if specified
|
||||
if templSchemaFilter != "" {
|
||||
fmt.Fprintf(os.Stderr, "Filtering to schema: %s\n", templSchemaFilter)
|
||||
found := false
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name == templSchemaFilter {
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("schema not found: %s", templSchemaFilter)
|
||||
}
|
||||
}
|
||||
|
||||
// Create template writer
|
||||
fmt.Fprintf(os.Stderr, "Loading template: %s\n", templTemplatePath)
|
||||
fmt.Fprintf(os.Stderr, "Execution mode: %s\n", templMode)
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"template_path": templTemplatePath,
|
||||
"mode": templMode,
|
||||
"filename_pattern": templFilenamePattern,
|
||||
}
|
||||
|
||||
writerOpts := &writers.WriterOptions{
|
||||
OutputPath: templOutputPath,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
writer, err := wtemplate.NewWriter(writerOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create template writer: %w", err)
|
||||
}
|
||||
|
||||
// Execute template
|
||||
fmt.Fprintf(os.Stderr, "\nExecuting template...\n")
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
// Print success message
|
||||
fmt.Fprintf(os.Stderr, "\n✓ Template executed successfully\n")
|
||||
if templOutputPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "Output written to: %s\n", templOutputPath)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Output written to stdout\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: relspec_test_password
|
||||
POSTGRES_DB: relspec_test
|
||||
ports:
|
||||
- "5433:5432" # Using 5433 to avoid conflicts with local PostgreSQL
|
||||
- "5439:5432" # Using 5439 to avoid conflicts with local PostgreSQL
|
||||
volumes:
|
||||
- ./tests/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
149
docs/DOMAINS_DRAWDB.md
Normal file
149
docs/DOMAINS_DRAWDB.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Domains and DrawDB Areas Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Domains provide a way to organize tables from potentially multiple schemas into logical business groupings. When working with DrawDB format, domains are automatically imported/exported as **Subject Areas** - a native DrawDB feature for visually grouping tables.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Writing Domains to DrawDB (Export)
|
||||
|
||||
When you export a database with domains to DrawDB format:
|
||||
|
||||
1. **Schema Areas** are created automatically for each schema (existing behavior)
|
||||
2. **Domain Areas** are created for each domain, calculated based on the positions of the tables they contain
|
||||
3. The domain area bounds are automatically calculated to encompass all its tables with a small padding
|
||||
|
||||
```go
|
||||
// Example: Creating a domain and exporting to DrawDB
|
||||
db := models.InitDatabase("mydb")
|
||||
|
||||
// Create an "authentication" domain
|
||||
authDomain := models.InitDomain("authentication")
|
||||
authDomain.Tables = append(authDomain.Tables,
|
||||
models.InitDomainTable("users", "public"),
|
||||
models.InitDomainTable("roles", "public"),
|
||||
models.InitDomainTable("permissions", "public"),
|
||||
)
|
||||
db.Domains = append(db.Domains, authDomain)
|
||||
|
||||
// Create a "financial" domain spanning multiple schemas
|
||||
finDomain := models.InitDomain("financial")
|
||||
finDomain.Tables = append(finDomain.Tables,
|
||||
models.InitDomainTable("accounts", "public"),
|
||||
models.InitDomainTable("transactions", "public"),
|
||||
models.InitDomainTable("ledger", "finance"), // Different schema!
|
||||
)
|
||||
db.Domains = append(db.Domains, finDomain)
|
||||
|
||||
// Write to DrawDB - domains become subject areas
|
||||
writer := drawdb.NewWriter(&writers.WriterOptions{
|
||||
OutputPath: "schema.json",
|
||||
})
|
||||
writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
The resulting DrawDB JSON will have Subject Areas for both:
|
||||
- "authentication" area containing the auth tables
|
||||
- "financial" area containing the financial tables from both schemas
|
||||
|
||||
### Reading Domains from DrawDB (Import)
|
||||
|
||||
When you import a DrawDB file with Subject Areas:
|
||||
|
||||
1. **Subject Areas** are automatically converted to **Domains**
|
||||
2. Tables are assigned to a domain if they fall within the area's visual bounds
|
||||
3. Table references include both the table name and schema name
|
||||
|
||||
```go
|
||||
// Example: Reading DrawDB with areas
|
||||
reader := drawdb.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "schema.json",
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Access domains
|
||||
for _, domain := range db.Domains {
|
||||
fmt.Printf("Domain: %s\n", domain.Name)
|
||||
for _, domainTable := range domain.Tables {
|
||||
fmt.Printf(" - %s.%s\n", domainTable.SchemaName, domainTable.TableName)
|
||||
|
||||
// Access the actual table reference if loaded
|
||||
if domainTable.RefTable != nil {
|
||||
fmt.Printf(" Description: %s\n", domainTable.RefTable.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Structure
|
||||
|
||||
```go
|
||||
type Domain struct {
|
||||
Name string // Domain name (e.g., "authentication", "user_data")
|
||||
Description string // Optional human-readable description
|
||||
Tables []*DomainTable // Tables belonging to this domain
|
||||
Comment string // Optional comment
|
||||
Metadata map[string]any // Extensible metadata
|
||||
Sequence uint // Ordering hint
|
||||
}
|
||||
|
||||
type DomainTable struct {
|
||||
TableName string // Table name
|
||||
SchemaName string // Schema containing the table
|
||||
Sequence uint // Ordering hint
|
||||
RefTable *Table // Pointer to actual table (in-memory only, not serialized)
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Schema Domains
|
||||
|
||||
One of the key features of domains is that they can span multiple schemas:
|
||||
|
||||
```
|
||||
Domain: "user_data"
|
||||
├── public.users
|
||||
├── public.profiles
|
||||
├── public.user_preferences
|
||||
├── auth.user_sessions
|
||||
└── auth.mfa_devices
|
||||
```
|
||||
|
||||
This allows you to organize related tables even when they're stored in different schemas.
|
||||
|
||||
## Visual Organization in DrawDB
|
||||
|
||||
When viewing the exported DrawDB file in DrawDB Editor:
|
||||
|
||||
1. **Schema areas** appear in one color (original behavior)
|
||||
2. **Domain areas** appear in a different color
|
||||
3. Domain area bounds are calculated to fit all contained tables
|
||||
4. Areas can overlap - a table can visually belong to multiple areas
|
||||
|
||||
## Integration with Other Formats
|
||||
|
||||
Currently, domain/area integration is implemented for DrawDB format.
|
||||
|
||||
To implement similar functionality for other formats:
|
||||
|
||||
1. Identify if the format has a native grouping/area feature
|
||||
2. Add conversion logic in the reader to map format areas → Domain model
|
||||
3. Add conversion logic in the writer to map Domain model → format areas
|
||||
|
||||
Example formats that could support domains:
|
||||
- **DBML**: Could use DBML's `TableGroup` feature
|
||||
- **DrawDB**: ✅ Already implemented (Subject Areas)
|
||||
- **GraphQL**: Could use schema directives
|
||||
- **Custom formats**: Implement as needed
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Keep domains focused**: Each domain should represent a distinct business area
|
||||
2. **Document purposes**: Use Description and Comment fields to explain each domain
|
||||
3. **Use meaningful names**: Domain names should clearly reflect their purpose
|
||||
4. **Maintain schema consistency**: Keep related tables together in the same schema when possible
|
||||
5. **Use metadata**: Store tool-specific information in the Metadata field
|
||||
360
docs/SCRIPTS_COMMAND.md
Normal file
360
docs/SCRIPTS_COMMAND.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# RelSpec Scripts Command
|
||||
|
||||
The `relspec scripts` command provides tools for managing and executing SQL migration scripts from a directory structure.
|
||||
|
||||
## Overview
|
||||
|
||||
The scripts command supports two main operations:
|
||||
- **list**: List SQL scripts from a directory in execution order
|
||||
- **execute**: Execute SQL scripts against a PostgreSQL database
|
||||
|
||||
Scripts are read from a directory (recursively) and executed in a deterministic order based on **Priority** (ascending) and **Sequence** (ascending).
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
SQL scripts must follow this naming pattern (both separators are supported):
|
||||
|
||||
```
|
||||
{priority}_{sequence}_{name}.{sql|pgsql} (underscore format)
|
||||
{priority}-{sequence}-{name}.{sql|pgsql} (hyphen format)
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- **priority**: Integer (0-9999) - Execution priority level (lower executes first)
|
||||
- **sequence**: Integer (0-9999) - Order within priority level (lower executes first)
|
||||
- **separator**: Underscore `_` or hyphen `-` (both formats can be mixed)
|
||||
- **name**: Descriptive name (alphanumeric, underscores, hyphens)
|
||||
- **extension**: `.sql` or `.pgsql`
|
||||
|
||||
### Valid Examples
|
||||
|
||||
**Underscore format:**
|
||||
```
|
||||
1_001_create_users.sql # Priority 1, Sequence 1
|
||||
1_002_create_posts.sql # Priority 1, Sequence 2
|
||||
1_003_create_comments.pgsql # Priority 1, Sequence 3
|
||||
2_001_add_indexes.sql # Priority 2, Sequence 1
|
||||
2_002_add_constraints.sql # Priority 2, Sequence 2
|
||||
3_001_seed_users.sql # Priority 3, Sequence 1
|
||||
```
|
||||
|
||||
**Hyphen format:**
|
||||
```
|
||||
1-001-create-users.sql # Priority 1, Sequence 1
|
||||
1-002-create-posts.sql # Priority 1, Sequence 2
|
||||
1-003-create-comments.pgsql # Priority 1, Sequence 3
|
||||
10-10-create-newid.pgsql # Priority 10, Sequence 10
|
||||
```
|
||||
|
||||
**Mixed format (both in same directory):**
|
||||
```
|
||||
1_001_create_users.sql # Priority 1, Sequence 1 (underscore)
|
||||
1-002-create-posts.sql # Priority 1, Sequence 2 (hyphen)
|
||||
2_001_add_indexes.sql # Priority 2, Sequence 1 (underscore)
|
||||
```
|
||||
|
||||
**Execution Order**: 1→2→3→4→5→6 (sorted by Priority, then Sequence)
|
||||
|
||||
### Invalid Examples (Will be ignored)
|
||||
|
||||
```
|
||||
migration.sql # Missing priority/sequence
|
||||
create_users.sql # Missing priority/sequence
|
||||
1_create_users.sql # Missing sequence
|
||||
1_001_test.txt # Wrong extension
|
||||
README.md # Not a SQL file
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Scripts can be organized in subdirectories. The scanner recursively finds all matching SQL files:
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 1_001_create_schema.sql
|
||||
├── 1_002_create_users.sql
|
||||
├── tables/
|
||||
│ ├── 1_003_create_posts.sql
|
||||
│ └── 1_004_create_comments.pgsql
|
||||
├── indexes/
|
||||
│ └── 2_001_add_indexes.sql
|
||||
└── data/
|
||||
└── 3_001_seed_data.sql
|
||||
```
|
||||
|
||||
All files will be found and executed in Priority→Sequence order regardless of directory structure.
|
||||
|
||||
## Commands
|
||||
|
||||
### relspec scripts list
|
||||
|
||||
List all SQL scripts in a directory and show their execution order.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
relspec scripts list --dir <directory> [flags]
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
- `--dir <path>` (required): Directory containing SQL scripts
|
||||
- `--schema <name>`: Schema name (default: "public")
|
||||
- `--database <name>`: Database name (default: "database")
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
relspec scripts list --dir ./migrations
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
=== SQL Scripts List ===
|
||||
Directory: ./migrations
|
||||
|
||||
Found 5 script(s) in execution order:
|
||||
|
||||
No. Priority Sequence Name Lines
|
||||
---- -------- -------- ------------------------------ -----
|
||||
1 1 1 create_users 7
|
||||
2 1 2 create_posts 8
|
||||
3 2 1 add_indexes 4
|
||||
4 2 2 add_constraints 6
|
||||
5 3 1 seed_data 4
|
||||
```
|
||||
|
||||
### relspec scripts execute
|
||||
|
||||
Execute SQL scripts from a directory against a PostgreSQL database.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
relspec scripts execute --dir <directory> --conn <connection-string> [flags]
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
- `--dir <path>` (required): Directory containing SQL scripts
|
||||
- `--conn <string>` (required): PostgreSQL connection string
|
||||
- `--schema <name>`: Schema name (default: "public")
|
||||
- `--database <name>`: Database name (default: "database")
|
||||
|
||||
**Connection String Formats:**
|
||||
|
||||
```bash
|
||||
# Standard PostgreSQL URLs
|
||||
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
|
||||
|
||||
# Key-value format
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Execute migration scripts
|
||||
relspec scripts execute \
|
||||
--dir ./migrations \
|
||||
--conn "postgres://user:pass@localhost:5432/mydb"
|
||||
|
||||
# Execute with custom schema
|
||||
relspec scripts execute \
|
||||
--dir ./migrations \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--schema public
|
||||
|
||||
# Execute with SSL disabled
|
||||
relspec scripts execute \
|
||||
--dir ./sql \
|
||||
--conn "postgres://user:pass@localhost/db?sslmode=disable"
|
||||
|
||||
# Execute using key-value connection string
|
||||
relspec scripts execute \
|
||||
--dir ./migrations \
|
||||
--conn "host=localhost port=5432 user=admin password=secret dbname=prod"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
=== SQL Scripts Execution ===
|
||||
Started at: 2025-12-30 22:30:15
|
||||
Directory: ./migrations
|
||||
Database: postgres://user:***@localhost:5432/mydb
|
||||
|
||||
[1/2] Reading SQL scripts...
|
||||
✓ Found 4 script(s)
|
||||
|
||||
[2/2] Executing scripts in order (Priority → Sequence)...
|
||||
|
||||
Executing script: create_users (Priority=1, Sequence=1)
|
||||
✓ Successfully executed: create_users
|
||||
Executing script: create_posts (Priority=1, Sequence=2)
|
||||
✓ Successfully executed: create_posts
|
||||
Executing script: add_indexes (Priority=2, Sequence=1)
|
||||
✓ Successfully executed: add_indexes
|
||||
Executing script: seed_data (Priority=2, Sequence=2)
|
||||
✓ Successfully executed: seed_data
|
||||
|
||||
=== Execution Complete ===
|
||||
Completed at: 2025-12-30 22:30:16
|
||||
Successfully executed 4 script(s)
|
||||
```
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
### Execution Order
|
||||
|
||||
Scripts are **always** executed in this order:
|
||||
1. Sort by **Priority** (ascending)
|
||||
2. Within same priority, sort by **Sequence** (ascending)
|
||||
|
||||
Example:
|
||||
```
|
||||
Priority 1, Sequence 1 → Executes 1st
|
||||
Priority 1, Sequence 2 → Executes 2nd
|
||||
Priority 1, Sequence 10 → Executes 3rd
|
||||
Priority 2, Sequence 1 → Executes 4th
|
||||
Priority 2, Sequence 5 → Executes 5th
|
||||
Priority 10, Sequence 1 → Executes 6th
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Stop on First Error**: Execution stops immediately when any script fails
|
||||
- **No Automatic Rollback**: Scripts executed before the failure remain committed
|
||||
- **Error Details**: Full error message with script name, priority, and sequence
|
||||
|
||||
Example error output:
|
||||
```
|
||||
Executing script: add_indexes (Priority=2, Sequence=1)
|
||||
Error: execution failed: failed to execute script add_indexes (Priority=2, Sequence=1):
|
||||
ERROR: syntax error at or near "IDNEX" (SQLSTATE 42601)
|
||||
```
|
||||
|
||||
### Transaction Behavior
|
||||
|
||||
- Each script executes in its own implicit transaction (PostgreSQL default)
|
||||
- No automatic transaction wrapping across multiple scripts
|
||||
- For atomic migrations, manually wrap SQL in `BEGIN/COMMIT` blocks
|
||||
|
||||
### Empty Scripts
|
||||
|
||||
Scripts with empty SQL content are silently skipped.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Development Migrations
|
||||
|
||||
Organize database changes by priority levels:
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 1_xxx_schema.sql # Priority 1: Core schema
|
||||
├── 1_xxx_tables.sql
|
||||
├── 2_xxx_indexes.sql # Priority 2: Performance
|
||||
├── 2_xxx_constraints.sql
|
||||
└── 3_xxx_seed.sql # Priority 3: Data
|
||||
```
|
||||
|
||||
### Multi-Environment Deployments
|
||||
|
||||
Use priority levels for environment-specific scripts:
|
||||
|
||||
```
|
||||
deploy/
|
||||
├── 1_xxx_core_schema.sql # Priority 1: All environments
|
||||
├── 2_xxx_dev_data.sql # Priority 2: Dev only
|
||||
├── 2_xxx_staging_data.sql # Priority 2: Staging only
|
||||
└── 3_xxx_prod_data.sql # Priority 3: Production only
|
||||
```
|
||||
|
||||
### Incremental Rollouts
|
||||
|
||||
Use sequence for ordered feature rollouts:
|
||||
|
||||
```
|
||||
features/
|
||||
├── 1_001_feature_a_schema.sql
|
||||
├── 1_002_feature_a_data.sql
|
||||
├── 1_003_feature_b_schema.sql
|
||||
├── 1_004_feature_b_data.sql
|
||||
```
|
||||
|
||||
## Integration with RelSpec
|
||||
|
||||
The scripts command uses:
|
||||
- **Reader**: `pkg/readers/sqldir/` - Reads SQL files into `models.Schema.Scripts`
|
||||
- **Writer**: `pkg/writers/sqlexec/` - Executes scripts from `models.Schema.Scripts`
|
||||
|
||||
You can use these packages programmatically:
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
)
|
||||
|
||||
// Read scripts
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "./migrations",
|
||||
})
|
||||
db, _ := reader.ReadDatabase()
|
||||
|
||||
// Execute scripts
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/mydb",
|
||||
},
|
||||
})
|
||||
writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Naming
|
||||
|
||||
- Use zero-padded sequences: `001`, `002`, `010` (not `1`, `2`, `10`)
|
||||
- Use descriptive names: `create_users_table`, not `table1`
|
||||
- Group related changes: same priority for related DDL
|
||||
|
||||
### Organization
|
||||
|
||||
- Keep scripts small and focused (one logical change per file)
|
||||
- Use priority levels to organize phases (schema → indexes → data)
|
||||
- Document complex migrations with SQL comments
|
||||
|
||||
### Safety
|
||||
|
||||
- Always test migrations in development first
|
||||
- Use `scripts list` to verify execution order before running
|
||||
- Back up production databases before executing
|
||||
- Consider using transactions for critical changes
|
||||
- Review generated SQL before execution
|
||||
|
||||
### Version Control
|
||||
|
||||
- Commit scripts to version control
|
||||
- Never modify executed scripts (create new ones instead)
|
||||
- Use meaningful commit messages
|
||||
- Tag releases with migration checkpoints
|
||||
|
||||
## Limitations
|
||||
|
||||
- PostgreSQL only (currently)
|
||||
- No built-in rollback support
|
||||
- No migration state tracking (no "already executed" detection)
|
||||
- No dry-run mode
|
||||
- Stops on first error (no partial execution tracking)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future features:
|
||||
- Migration state tracking (executed scripts table)
|
||||
- Rollback script support (using `models.Script.Rollback` field)
|
||||
- Dry-run mode (validate without executing)
|
||||
- Transaction wrapping (all-or-nothing execution)
|
||||
- Multi-database support (MySQL, SQLite, etc.)
|
||||
- Parallel execution for independent scripts
|
||||
393
docs/SCRIPTS_EXAMPLES.md
Normal file
393
docs/SCRIPTS_EXAMPLES.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# RelSpec Scripts Command - Quick Examples
|
||||
|
||||
## Basic Workflow
|
||||
|
||||
### 1. Create migration directory structure
|
||||
|
||||
```bash
|
||||
mkdir -p migrations
|
||||
```
|
||||
|
||||
### 2. Create migration scripts
|
||||
|
||||
Both underscore and hyphen formats are supported. Examples below use underscore format,
|
||||
but you can also use: `1-001-create-users-table.sql`
|
||||
|
||||
```bash
|
||||
# Priority 1: Core schema
|
||||
cat > migrations/1_001_create_users_table.sql << 'EOF'
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
EOF
|
||||
|
||||
cat > migrations/1_002_create_posts_table.sql << 'EOF'
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
published BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
EOF
|
||||
|
||||
# Priority 2: Additional indexes
|
||||
cat > migrations/2_001_add_post_indexes.sql << 'EOF'
|
||||
CREATE INDEX idx_posts_user_id ON posts(user_id);
|
||||
CREATE INDEX idx_posts_published ON posts(published);
|
||||
CREATE INDEX idx_posts_created_at ON posts(created_at);
|
||||
EOF
|
||||
|
||||
# Priority 3: Seed data
|
||||
cat > migrations/3_001_seed_admin_user.sql << 'EOF'
|
||||
INSERT INTO users (username, email, password_hash)
|
||||
VALUES ('admin', 'admin@example.com', 'hashed_password_here')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. List scripts to verify order
|
||||
|
||||
```bash
|
||||
relspec scripts list --dir migrations
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
=== SQL Scripts List ===
|
||||
Directory: migrations
|
||||
|
||||
Found 4 script(s) in execution order:
|
||||
|
||||
No. Priority Sequence Name Lines
|
||||
---- -------- -------- ------------------------------ -----
|
||||
1 1 1 create_users_table 13
|
||||
2 1 2 create_posts_table 11
|
||||
3 2 1 add_post_indexes 4
|
||||
4 3 1 seed_admin_user 4
|
||||
```
|
||||
|
||||
### 4. Execute against database
|
||||
|
||||
```bash
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://myuser:mypass@localhost:5432/myapp"
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Example 1: E-commerce Database Setup
|
||||
|
||||
```bash
|
||||
# Directory structure
|
||||
migrations/
|
||||
├── 1_001_create_users.sql
|
||||
├── 1_002_create_products.sql
|
||||
├── 1_003_create_orders.sql
|
||||
├── 1_004_create_order_items.sql
|
||||
├── 2_001_add_indexes.sql
|
||||
├── 2_002_add_constraints.sql
|
||||
├── 3_001_seed_categories.sql
|
||||
└── 3_002_seed_sample_products.sql
|
||||
|
||||
# Execute
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://ecommerce_user:pass@db.example.com:5432/ecommerce_prod?sslmode=require"
|
||||
```
|
||||
|
||||
### Example 2: Multi-Schema Database
|
||||
|
||||
```bash
|
||||
# Organize by schema using subdirectories
|
||||
migrations/
|
||||
├── public/
|
||||
│ ├── 1_001_create_users.sql
|
||||
│ └── 1_002_create_sessions.sql
|
||||
├── analytics/
|
||||
│ ├── 1_001_create_events.sql
|
||||
│ └── 2_001_create_views.sql
|
||||
└── reporting/
|
||||
└── 1_001_create_reports.sql
|
||||
|
||||
# Execute (all schemas processed together)
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://localhost/multi_schema_db" \
|
||||
--schema public
|
||||
```
|
||||
|
||||
### Example 3: Development Environment Setup
|
||||
|
||||
```bash
|
||||
# Create local development database
|
||||
createdb myapp_dev
|
||||
|
||||
# Run migrations
|
||||
relspec scripts execute \
|
||||
--dir ./db/migrations \
|
||||
--conn "postgres://localhost/myapp_dev?sslmode=disable"
|
||||
|
||||
# Verify
|
||||
psql myapp_dev -c "\dt"
|
||||
```
|
||||
|
||||
### Example 4: CI/CD Pipeline
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
- name: Run database migrations
|
||||
run: |
|
||||
relspec scripts list --dir migrations
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "${{ secrets.DATABASE_URL }}"
|
||||
```
|
||||
|
||||
### Example 5: Docker Compose Integration
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: myapp
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypass
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
migrate:
|
||||
image: relspec:latest
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- ./migrations:/migrations
|
||||
command: >
|
||||
scripts execute
|
||||
--dir /migrations
|
||||
--conn "postgres://myuser:mypass@postgres:5432/myapp"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run migrations with docker-compose
|
||||
docker-compose up -d postgres
|
||||
sleep 5 # Wait for postgres to be ready
|
||||
docker-compose run --rm migrate
|
||||
```
|
||||
|
||||
### Example 6: Incremental Feature Rollout
|
||||
|
||||
```bash
|
||||
# Feature branch structure
|
||||
migrations/
|
||||
├── 1_100_user_profiles_schema.sql # Feature: User profiles
|
||||
├── 1_101_user_profiles_constraints.sql
|
||||
├── 1_102_user_profiles_indexes.sql
|
||||
├── 2_100_notifications_schema.sql # Feature: Notifications
|
||||
├── 2_101_notifications_constraints.sql
|
||||
└── 2_102_notifications_indexes.sql
|
||||
|
||||
# Deploy just user profiles (Priority 1)
|
||||
# Then later deploy notifications (Priority 2)
|
||||
```
|
||||
|
||||
### Example 7: Rollback Strategy (Manual)
|
||||
|
||||
```bash
|
||||
# Forward migration
|
||||
cat > migrations/1_001_add_column.sql << 'EOF'
|
||||
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
|
||||
EOF
|
||||
|
||||
# Create manual rollback script (not auto-executed)
|
||||
cat > rollbacks/1_001_remove_column.sql << 'EOF'
|
||||
ALTER TABLE users DROP COLUMN phone;
|
||||
EOF
|
||||
|
||||
# If needed, manually execute rollback
|
||||
psql myapp -f rollbacks/1_001_remove_column.sql
|
||||
```
|
||||
|
||||
### Example 8: Complex Schema Changes
|
||||
|
||||
```bash
|
||||
# migrations/1_001_alter_users_table.sql
|
||||
BEGIN;
|
||||
|
||||
-- Add new column
|
||||
ALTER TABLE users ADD COLUMN full_name VARCHAR(200);
|
||||
|
||||
-- Populate from existing data
|
||||
UPDATE users SET full_name = username WHERE full_name IS NULL;
|
||||
|
||||
-- Make it required
|
||||
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
|
||||
|
||||
-- Add index
|
||||
CREATE INDEX idx_users_full_name ON users(full_name);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Execute:
|
||||
```bash
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://localhost/myapp"
|
||||
```
|
||||
|
||||
## File Naming Format Examples
|
||||
|
||||
### Underscore Format (Traditional)
|
||||
```
|
||||
migrations/
|
||||
├── 1_001_create_users.sql
|
||||
├── 1_002_create_posts.sql
|
||||
├── 2_001_add_indexes.sql
|
||||
└── 3_001_seed_data.sql
|
||||
```
|
||||
|
||||
### Hyphen Format (Alternative)
|
||||
```
|
||||
migrations/
|
||||
├── 1-001-create-users.sql
|
||||
├── 1-002-create-posts.sql
|
||||
├── 10-10-create-newid.pgsql
|
||||
└── 2-001-add-indexes.sql
|
||||
```
|
||||
|
||||
### Mixed Format (Both in Same Directory)
|
||||
```
|
||||
migrations/
|
||||
├── 1_001_create_users.sql # Underscore format
|
||||
├── 1-002-create-posts.sql # Hyphen format
|
||||
├── 2_001_add_indexes.sql # Underscore format
|
||||
└── 10-10-special-migration.pgsql # Hyphen format
|
||||
```
|
||||
|
||||
**Note:** All three approaches work identically - use whichever naming style you prefer!
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Schema → Indexes → Constraints → Data
|
||||
|
||||
```
|
||||
1_xxx_*.sql # Tables and basic structure
|
||||
2_xxx_*.sql # Indexes for performance
|
||||
3_xxx_*.sql # Foreign keys and constraints
|
||||
4_xxx_*.sql # Seed/reference data
|
||||
```
|
||||
|
||||
### Pattern 2: Feature-Based Organization
|
||||
|
||||
```
|
||||
1_001_feature_auth_users.sql
|
||||
1_002_feature_auth_sessions.sql
|
||||
1_003_feature_auth_permissions.sql
|
||||
2_001_feature_blog_posts.sql
|
||||
2_002_feature_blog_comments.sql
|
||||
3_001_feature_payments_transactions.sql
|
||||
```
|
||||
|
||||
### Pattern 3: Date-Based Versioning
|
||||
|
||||
```
|
||||
1_20250130_create_users.sql
|
||||
2_20250131_add_user_indexes.sql
|
||||
3_20250201_create_posts.sql
|
||||
```
|
||||
|
||||
### Pattern 4: Environment-Specific Scripts
|
||||
|
||||
```bash
|
||||
# Base migrations (all environments)
|
||||
migrations/base/
|
||||
├── 1_001_create_users.sql
|
||||
├── 1_002_create_products.sql
|
||||
|
||||
# Development-specific
|
||||
migrations/dev/
|
||||
└── 9_001_seed_test_data.sql
|
||||
|
||||
# Production-specific
|
||||
migrations/prod/
|
||||
└── 9_001_seed_production_config.sql
|
||||
|
||||
# Execute different paths based on environment
|
||||
ENV=dev
|
||||
relspec scripts execute \
|
||||
--dir migrations/base \
|
||||
--conn "postgres://localhost/myapp_${ENV}"
|
||||
|
||||
relspec scripts execute \
|
||||
--dir migrations/${ENV} \
|
||||
--conn "postgres://localhost/myapp_${ENV}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check script order before execution
|
||||
```bash
|
||||
relspec scripts list --dir migrations
|
||||
```
|
||||
|
||||
### Test against local database first
|
||||
```bash
|
||||
# Create test database
|
||||
createdb myapp_test
|
||||
|
||||
# Test migrations
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://localhost/myapp_test"
|
||||
|
||||
# Inspect results
|
||||
psql myapp_test
|
||||
|
||||
# Cleanup
|
||||
dropdb myapp_test
|
||||
```
|
||||
|
||||
### Validate SQL syntax
|
||||
```bash
|
||||
# Use PostgreSQL to check syntax without executing
|
||||
for f in migrations/*.sql; do
|
||||
echo "Checking $f..."
|
||||
psql myapp -c "BEGIN; \i $f; ROLLBACK;" --single-transaction
|
||||
done
|
||||
```
|
||||
|
||||
### Debug connection issues
|
||||
```bash
|
||||
# Test connection string
|
||||
psql "postgres://user:pass@localhost:5432/myapp"
|
||||
|
||||
# If that works, use the same string for relspec
|
||||
relspec scripts execute \
|
||||
--dir migrations \
|
||||
--conn "postgres://user:pass@localhost:5432/myapp"
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Always review execution order** with `list` before running `execute`
|
||||
2. **Test in development** before running against production
|
||||
3. **Use zero-padded sequences** (001, 002, not 1, 2) for consistent sorting
|
||||
4. **Keep scripts idempotent** when possible (use IF NOT EXISTS, ON CONFLICT, etc.)
|
||||
5. **Back up production** before running migrations
|
||||
6. **Use transactions** for complex multi-statement migrations
|
||||
7. **Document breaking changes** with SQL comments in the migration files
|
||||
8. **Version control everything** - commit migrations with code changes
|
||||
572
docs/TEMPLATE_MODE.md
Normal file
572
docs/TEMPLATE_MODE.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# RelSpec Template Mode
|
||||
|
||||
The `templ` command allows you to transform database schemas using custom Go text templates. It provides powerful template functions and flexible execution modes for generating any type of output from your database schema.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Execution Modes](#execution-modes)
|
||||
- [Template Functions](#template-functions)
|
||||
- [String Utilities](#string-utilities)
|
||||
- [Type Conversion](#type-conversion)
|
||||
- [Filtering](#filtering)
|
||||
- [Formatting](#formatting)
|
||||
- [Loop Helpers](#loop-helpers)
|
||||
- [Sorting Helpers](#sorting-helpers)
|
||||
- [Safe Access](#safe-access)
|
||||
- [Utility Functions](#utility-functions)
|
||||
- [Data Model](#data-model)
|
||||
- [Examples](#examples)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Generate documentation from a database
|
||||
relspec templ --from pgsql --from-conn "postgres://user:pass@localhost/db" \
|
||||
--template docs.tmpl --output schema-docs.md
|
||||
|
||||
# Generate TypeScript models (one file per table)
|
||||
relspec templ --from dbml --from-path schema.dbml \
|
||||
--template model.tmpl --mode table \
|
||||
--output ./models/ \
|
||||
--filename-pattern "{{.Name | toCamelCase}}.ts"
|
||||
|
||||
# Output to stdout
|
||||
relspec templ --from json --from-path schema.json \
|
||||
--template report.tmpl
|
||||
```
|
||||
|
||||
## Execution Modes
|
||||
|
||||
The `--mode` flag controls how the template is executed:
|
||||
|
||||
| Mode | Description | Output | When to Use |
|
||||
|------|-------------|--------|-------------|
|
||||
| `database` | Execute once for entire database | Single file | Documentation, reports, overview files |
|
||||
| `schema` | Execute once per schema | One file per schema | Schema-specific documentation |
|
||||
| `domain` | Execute once per domain | One file per domain | Domain-based documentation, domain exports |
|
||||
| `script` | Execute once per script | One file per script | Script processing |
|
||||
| `table` | Execute once per table | One file per table | Model generation, table docs |
|
||||
|
||||
### Filename Patterns
|
||||
|
||||
For multi-file modes (`schema`, `domain`, `script`, `table`), use `--filename-pattern` to control output filenames:
|
||||
|
||||
```bash
|
||||
# Default pattern
|
||||
--filename-pattern "{{.Name}}.txt"
|
||||
|
||||
# With transformations
|
||||
--filename-pattern "{{.Name | toCamelCase}}.ts"
|
||||
|
||||
# Nested directories
|
||||
--filename-pattern "{{.Schema}}/{{.Name}}.md"
|
||||
|
||||
# Complex patterns
|
||||
--filename-pattern "{{.ParentSchema.Name}}/models/{{.Name | toPascalCase}}Model.java"
|
||||
```
|
||||
|
||||
## Template Functions
|
||||
|
||||
### String Utilities
|
||||
|
||||
Transform and manipulate strings in your templates.
|
||||
|
||||
| Function | Description | Example | Output |
|
||||
|----------|-------------|---------|--------|
|
||||
| `toUpper` | Convert to uppercase | `{{ "hello" \| toUpper }}` | `HELLO` |
|
||||
| `toLower` | Convert to lowercase | `{{ "HELLO" \| toLower }}` | `hello` |
|
||||
| `toCamelCase` | Convert to camelCase | `{{ "user_name" \| toCamelCase }}` | `userName` |
|
||||
| `toPascalCase` | Convert to PascalCase | `{{ "user_name" \| toPascalCase }}` | `UserName` |
|
||||
| `toSnakeCase` | Convert to snake_case | `{{ "UserName" \| toSnakeCase }}` | `user_name` |
|
||||
| `toKebabCase` | Convert to kebab-case | `{{ "UserName" \| toKebabCase }}` | `user-name` |
|
||||
| `pluralize` | Convert to plural | `{{ "user" \| pluralize }}` | `users` |
|
||||
| `singularize` | Convert to singular | `{{ "users" \| singularize }}` | `user` |
|
||||
| `title` | Capitalize first letter | `{{ "hello world" \| title }}` | `Hello World` |
|
||||
| `trim` | Trim whitespace | `{{ " hello " \| trim }}` | `hello` |
|
||||
| `trimPrefix` | Remove prefix | `{{ trimPrefix "tbl_users" "tbl_" }}` | `users` |
|
||||
| `trimSuffix` | Remove suffix | `{{ trimSuffix "users_old" "_old" }}` | `users` |
|
||||
| `replace` | Replace occurrences | `{{ replace "hello" "l" "L" -1 }}` | `heLLo` |
|
||||
| `stringContains` | Check if contains substring | `{{ stringContains "hello" "ell" }}` | `true` |
|
||||
| `hasPrefix` | Check if starts with | `{{ hasPrefix "hello" "hel" }}` | `true` |
|
||||
| `hasSuffix` | Check if ends with | `{{ hasSuffix "hello" "llo" }}` | `true` |
|
||||
| `split` | Split by separator | `{{ split "a,b,c" "," }}` | `[a b c]` |
|
||||
| `join` | Join with separator | `{{ join (list "a" "b") "," }}` | `a,b` |
|
||||
|
||||
### Type Conversion
|
||||
|
||||
Convert SQL types to various programming language types.
|
||||
|
||||
| Function | Parameters | Description | Example |
|
||||
|----------|------------|-------------|---------|
|
||||
| `sqlToGo` | `sqlType`, `nullable` | SQL to Go | `{{ sqlToGo "varchar" true }}` → `string` |
|
||||
| `sqlToTypeScript` | `sqlType`, `nullable` | SQL to TypeScript | `{{ sqlToTypeScript "integer" false }}` → `number \| null` |
|
||||
| `sqlToJava` | `sqlType`, `nullable` | SQL to Java | `{{ sqlToJava "varchar" true }}` → `String` |
|
||||
| `sqlToPython` | `sqlType` | SQL to Python | `{{ sqlToPython "integer" }}` → `int` |
|
||||
| `sqlToRust` | `sqlType`, `nullable` | SQL to Rust | `{{ sqlToRust "varchar" false }}` → `Option<String>` |
|
||||
| `sqlToCSharp` | `sqlType`, `nullable` | SQL to C# | `{{ sqlToCSharp "integer" false }}` → `int?` |
|
||||
| `sqlToPhp` | `sqlType`, `nullable` | SQL to PHP | `{{ sqlToPhp "varchar" false }}` → `?string` |
|
||||
|
||||
**Supported SQL Types:**
|
||||
- Integer: `integer`, `int`, `smallint`, `bigint`, `serial`, `bigserial`
|
||||
- String: `text`, `varchar`, `char`, `character`, `citext`
|
||||
- Boolean: `boolean`, `bool`
|
||||
- Float: `real`, `float`, `double precision`, `numeric`, `decimal`
|
||||
- Date/Time: `timestamp`, `date`, `time`, `timestamptz`
|
||||
- Binary: `bytea`
|
||||
- Special: `uuid`, `json`, `jsonb`, `array`
|
||||
|
||||
### Filtering
|
||||
|
||||
Filter and select specific database objects.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `filterTables` | Filter tables by pattern | `{{ filterTables .Schema.Tables "user_*" }}` |
|
||||
| `filterTablesByPattern` | Alias for filterTables | `{{ filterTablesByPattern .Schema.Tables "temp_*" }}` |
|
||||
| `filterColumns` | Filter columns by pattern | `{{ filterColumns .Table.Columns "*_id" }}` |
|
||||
| `filterColumnsByType` | Filter by SQL type | `{{ filterColumnsByType .Table.Columns "varchar" }}` |
|
||||
| `filterPrimaryKeys` | Get primary key columns | `{{ filterPrimaryKeys .Table.Columns }}` |
|
||||
| `filterForeignKeys` | Get foreign key constraints | `{{ filterForeignKeys .Table.Constraints }}` |
|
||||
| `filterUniqueConstraints` | Get unique constraints | `{{ filterUniqueConstraints .Table.Constraints }}` |
|
||||
| `filterCheckConstraints` | Get check constraints | `{{ filterCheckConstraints .Table.Constraints }}` |
|
||||
| `filterNullable` | Get nullable columns | `{{ filterNullable .Table.Columns }}` |
|
||||
| `filterNotNull` | Get non-nullable columns | `{{ filterNotNull .Table.Columns }}` |
|
||||
|
||||
**Pattern Matching:**
|
||||
- `*` - Match any characters
|
||||
- `?` - Match single character
|
||||
- Example: `user_*` matches `user_profile`, `user_settings`
|
||||
|
||||
### Formatting
|
||||
|
||||
Format output and add structure to generated code.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `toJSON` | Convert to JSON | `{{ .Database \| toJSON }}` |
|
||||
| `toJSONPretty` | Pretty-print JSON | `{{ toJSONPretty .Table " " }}` |
|
||||
| `toYAML` | Convert to YAML | `{{ .Schema \| toYAML }}` |
|
||||
| `indent` | Indent by spaces | `{{ indent .Column.Description 4 }}` |
|
||||
| `indentWith` | Indent with prefix | `{{ indentWith .Comment " " }}` |
|
||||
| `escape` | Escape special chars | `{{ escape .Column.Default }}` |
|
||||
| `escapeQuotes` | Escape quotes only | `{{ escapeQuotes .String }}` |
|
||||
| `comment` | Add comment prefix | `{{ comment .Description "//" }}` |
|
||||
| `quoteString` | Add quotes | `{{ quoteString "value" }}` → `"value"` |
|
||||
| `unquoteString` | Remove quotes | `{{ unquoteString "\"value\"" }}` → `value` |
|
||||
|
||||
**Comment Styles:**
|
||||
- `//` - C/Go/JavaScript style
|
||||
- `#` - Python/Shell style
|
||||
- `--` - SQL style
|
||||
- `/* */` - Block comment style
|
||||
|
||||
### Loop Helpers
|
||||
|
||||
Iterate and manipulate collections.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `enumerate` | Add index to items | `{{ range enumerate .Tables }}{{ .Index }}: {{ .Value.Name }}{{ end }}` |
|
||||
| `batch` | Split into chunks | `{{ range batch .Columns 3 }}...{{ end }}` |
|
||||
| `chunk` | Alias for batch | `{{ range chunk .Columns 5 }}...{{ end }}` |
|
||||
| `reverse` | Reverse order | `{{ range reverse .Tables }}...{{ end }}` |
|
||||
| `first` | Get first N items | `{{ range first .Tables 5 }}...{{ end }}` |
|
||||
| `last` | Get last N items | `{{ range last .Tables 3 }}...{{ end }}` |
|
||||
| `skip` | Skip first N items | `{{ range skip .Tables 2 }}...{{ end }}` |
|
||||
| `take` | Take first N (alias) | `{{ range take .Tables 10 }}...{{ end }}` |
|
||||
| `concat` | Concatenate slices | `{{ $all := concat .Schema1.Tables .Schema2.Tables }}` |
|
||||
| `unique` | Remove duplicates | `{{ $unique := unique .Items }}` |
|
||||
| `sortBy` | Sort by field | `{{ $sorted := sortBy .Tables "Name" }}` |
|
||||
| `groupBy` | Group by field | `{{ $grouped := groupBy .Tables "Schema" }}` |
|
||||
|
||||
### Sorting Helpers
|
||||
|
||||
Sort database objects by name or sequence number. All sort functions modify the slice in-place.
|
||||
|
||||
**Schema Sorting:**
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `sortSchemasByName` | Sort schemas by name | `{{ sortSchemasByName .Database.Schemas false }}` |
|
||||
| `sortSchemasBySequence` | Sort schemas by sequence | `{{ sortSchemasBySequence .Database.Schemas false }}` |
|
||||
|
||||
**Table Sorting:**
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `sortTablesByName` | Sort tables by name | `{{ sortTablesByName .Schema.Tables false }}` |
|
||||
| `sortTablesBySequence` | Sort tables by sequence | `{{ sortTablesBySequence .Schema.Tables true }}` |
|
||||
|
||||
**Column Sorting:**
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `sortColumnsMapByName` | Convert column map to sorted slice by name | `{{ $cols := sortColumnsMapByName .Table.Columns false }}` |
|
||||
| `sortColumnsMapBySequence` | Convert column map to sorted slice by sequence | `{{ $cols := sortColumnsMapBySequence .Table.Columns false }}` |
|
||||
| `sortColumnsByName` | Sort column slice by name | `{{ sortColumnsByName $columns false }}` |
|
||||
| `sortColumnsBySequence` | Sort column slice by sequence | `{{ sortColumnsBySequence $columns true }}` |
|
||||
|
||||
**Other Object Sorting:**
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `sortViewsByName` | Sort views by name | `{{ sortViewsByName .Schema.Views false }}` |
|
||||
| `sortViewsBySequence` | Sort views by sequence | `{{ sortViewsBySequence .Schema.Views false }}` |
|
||||
| `sortSequencesByName` | Sort sequences by name | `{{ sortSequencesByName .Schema.Sequences false }}` |
|
||||
| `sortSequencesBySequence` | Sort sequences by sequence | `{{ sortSequencesBySequence .Schema.Sequences false }}` |
|
||||
| `sortIndexesMapByName` | Convert index map to sorted slice by name | `{{ $idx := sortIndexesMapByName .Table.Indexes false }}` |
|
||||
| `sortIndexesMapBySequence` | Convert index map to sorted slice by sequence | `{{ $idx := sortIndexesMapBySequence .Table.Indexes false }}` |
|
||||
| `sortIndexesByName` | Sort index slice by name | `{{ sortIndexesByName $indexes false }}` |
|
||||
| `sortIndexesBySequence` | Sort index slice by sequence | `{{ sortIndexesBySequence $indexes false }}` |
|
||||
| `sortConstraintsMapByName` | Convert constraint map to sorted slice by name | `{{ $cons := sortConstraintsMapByName .Table.Constraints false }}` |
|
||||
| `sortConstraintsByName` | Sort constraint slice by name | `{{ sortConstraintsByName $constraints false }}` |
|
||||
| `sortRelationshipsMapByName` | Convert relationship map to sorted slice by name | `{{ $rels := sortRelationshipsMapByName .Table.Relationships false }}` |
|
||||
| `sortRelationshipsByName` | Sort relationship slice by name | `{{ sortRelationshipsByName $relationships false }}` |
|
||||
| `sortScriptsByName` | Sort scripts by name | `{{ sortScriptsByName .Schema.Scripts false }}` |
|
||||
| `sortEnumsByName` | Sort enums by name | `{{ sortEnumsByName .Schema.Enums false }}` |
|
||||
|
||||
**Sort Parameters:**
|
||||
- Second parameter: `false` = ascending, `true` = descending
|
||||
- Example: `{{ sortTablesByName .Schema.Tables true }}` sorts descending (Z-A)
|
||||
|
||||
### Safe Access
|
||||
|
||||
Safely access nested data without panicking.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `get` | Get map value | `{{ get .Metadata "key" }}` |
|
||||
| `getOr` | Get with default | `{{ getOr .Metadata "key" "default" }}` |
|
||||
| `getPath` | Nested access | `{{ getPath .Config "database.host" }}` |
|
||||
| `getPathOr` | Nested with default | `{{ getPathOr .Config "db.port" 5432 }}` |
|
||||
| `safeIndex` | Safe array access | `{{ safeIndex .Tables 0 }}` |
|
||||
| `safeIndexOr` | Safe with default | `{{ safeIndexOr .Tables 0 nil }}` |
|
||||
| `has` | Check key exists | `{{ if has .Metadata "key" }}...{{ end }}` |
|
||||
| `hasPath` | Check nested path | `{{ if hasPath .Config "db.host" }}...{{ end }}` |
|
||||
| `keys` | Get map keys | `{{ range keys .Metadata }}...{{ end }}` |
|
||||
| `values` | Get map values | `{{ range values .Table.Columns }}...{{ end }}` |
|
||||
| `merge` | Merge maps | `{{ $merged := merge .Map1 .Map2 }}` |
|
||||
| `pick` | Select keys | `{{ $subset := pick .Metadata "name" "desc" }}` |
|
||||
| `omit` | Exclude keys | `{{ $filtered := omit .Metadata "internal" }}` |
|
||||
| `sliceContains` | Check contains | `{{ if sliceContains .Names "admin" }}...{{ end }}` |
|
||||
| `indexOf` | Find index | `{{ $idx := indexOf .Names "admin" }}` |
|
||||
| `pluck` | Extract field | `{{ $names := pluck .Tables "Name" }}` |
|
||||
|
||||
### Utility Functions
|
||||
|
||||
General-purpose template helpers.
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `add` | Add numbers | `{{ add 5 3 }}` → `8` |
|
||||
| `sub` | Subtract | `{{ sub 10 3 }}` → `7` |
|
||||
| `mul` | Multiply | `{{ mul 4 5 }}` → `20` |
|
||||
| `div` | Divide | `{{ div 10 2 }}` → `5` |
|
||||
| `mod` | Modulo | `{{ mod 10 3 }}` → `1` |
|
||||
| `default` | Default value | `{{ default "unknown" .Name }}` |
|
||||
| `dict` | Create map | `{{ $m := dict "key1" "val1" "key2" "val2" }}` |
|
||||
| `list` | Create list | `{{ $l := list "a" "b" "c" }}` |
|
||||
| `seq` | Number sequence | `{{ range seq 1 5 }}{{ . }}{{ end }}` → `12345` |
|
||||
|
||||
## Data Model
|
||||
|
||||
The data available in templates depends on the execution mode:
|
||||
|
||||
### Database Mode
|
||||
```go
|
||||
.Database // *models.Database - Full database
|
||||
.ParentDatabase // *models.Database - Same as .Database
|
||||
.FlatColumns // []*models.FlatColumn - All columns flattened
|
||||
.FlatTables // []*models.FlatTable - All tables flattened
|
||||
.FlatConstraints // []*models.FlatConstraint - All constraints
|
||||
.FlatRelationships // []*models.FlatRelationship - All relationships
|
||||
.Summary // *models.DatabaseSummary - Statistics
|
||||
.Metadata // map[string]interface{} - User metadata
|
||||
```
|
||||
|
||||
### Schema Mode
|
||||
```go
|
||||
.Schema // *models.Schema - Current schema
|
||||
.ParentDatabase // *models.Database - Parent database context
|
||||
.FlatColumns // []*models.FlatColumn - Schema's columns flattened
|
||||
.FlatTables // []*models.FlatTable - Schema's tables flattened
|
||||
.FlatConstraints // []*models.FlatConstraint - Schema's constraints
|
||||
.FlatRelationships // []*models.FlatRelationship - Schema's relationships
|
||||
.Summary // *models.DatabaseSummary - Statistics
|
||||
.Metadata // map[string]interface{} - User metadata
|
||||
```
|
||||
|
||||
### Domain Mode
|
||||
```go
|
||||
.Domain // *models.Domain - Current domain
|
||||
.ParentDatabase // *models.Database - Parent database context
|
||||
.Metadata // map[string]interface{} - User metadata
|
||||
```
|
||||
|
||||
### Table Mode
|
||||
```go
|
||||
.Table // *models.Table - Current table
|
||||
.ParentSchema // *models.Schema - Parent schema
|
||||
.ParentDatabase // *models.Database - Parent database context
|
||||
.Metadata // map[string]interface{} - User metadata
|
||||
```
|
||||
|
||||
### Script Mode
|
||||
```go
|
||||
.Script // *models.Script - Current script
|
||||
.ParentSchema // *models.Schema - Parent schema
|
||||
.ParentDatabase // *models.Database - Parent database context
|
||||
.Metadata // map[string]interface{} - User metadata
|
||||
```
|
||||
|
||||
### Model Structures
|
||||
|
||||
**Database:**
|
||||
- `.Name` - Database name
|
||||
- `.Schemas` - List of schemas
|
||||
- `.Domains` - List of domains (business domain groupings)
|
||||
- `.Description`, `.Comment` - Documentation
|
||||
|
||||
**Schema:**
|
||||
- `.Name` - Schema name
|
||||
- `.Tables` - List of tables
|
||||
- `.Views`, `.Sequences`, `.Scripts` - Other objects
|
||||
- `.Enums` - Enum types
|
||||
|
||||
**Domain:**
|
||||
- `.Name` - Domain name
|
||||
- `.Tables` - List of DomainTable references
|
||||
- `.Description`, `.Comment` - Documentation
|
||||
- `.Metadata` - Custom metadata map
|
||||
|
||||
**DomainTable:**
|
||||
- `.TableName` - Name of the table
|
||||
- `.SchemaName` - Schema containing the table
|
||||
- `.RefTable` - Pointer to actual Table object (if loaded)
|
||||
|
||||
**Table:**
|
||||
- `.Name` - Table name
|
||||
- `.Schema` - Schema name
|
||||
- `.Columns` - Map of columns (use `values` function to iterate)
|
||||
- `.Constraints` - Map of constraints
|
||||
- `.Indexes` - Map of indexes
|
||||
- `.Relationships` - Map of relationships
|
||||
- `.Description`, `.Comment` - Documentation
|
||||
|
||||
**Column:**
|
||||
- `.Name` - Column name
|
||||
- `.Type` - SQL type
|
||||
- `.NotNull` - Is NOT NULL
|
||||
- `.IsPrimaryKey` - Is primary key
|
||||
- `.Default` - Default value
|
||||
- `.Description`, `.Comment` - Documentation
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: TypeScript Interfaces (Table Mode)
|
||||
|
||||
**Template:** `typescript-interface.tmpl`
|
||||
```typescript
|
||||
// Generated from {{ .ParentDatabase.Name }}.{{ .ParentSchema.Name }}.{{ .Table.Name }}
|
||||
|
||||
export interface {{ .Table.Name | toPascalCase }} {
|
||||
{{- range .Table.Columns | values }}
|
||||
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type .NotNull }};
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{- $fks := filterForeignKeys .Table.Constraints }}
|
||||
{{- if $fks }}
|
||||
|
||||
// Foreign Keys:
|
||||
{{- range $fks }}
|
||||
// - {{ .Name }}: references {{ .ReferencedTable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Command:**
|
||||
```bash
|
||||
relspec templ --from pgsql --from-conn "..." \
|
||||
--template typescript-interface.tmpl \
|
||||
--mode table \
|
||||
--output ./src/types/ \
|
||||
--filename-pattern "{{.Name | toCamelCase}}.ts"
|
||||
```
|
||||
|
||||
### Example 2: Markdown Documentation (Database Mode)
|
||||
|
||||
**Template:** `database-docs.tmpl`
|
||||
```markdown
|
||||
# Database: {{ .Database.Name }}
|
||||
|
||||
{{ if .Database.Description }}{{ .Database.Description }}{{ end }}
|
||||
|
||||
**Statistics:**
|
||||
- Schemas: {{ len .Database.Schemas }}
|
||||
- Tables: {{ .Summary.TotalTables }}
|
||||
- Columns: {{ .Summary.TotalColumns }}
|
||||
|
||||
{{ range .Database.Schemas }}
|
||||
## Schema: {{ .Name }}
|
||||
|
||||
{{ range .Tables }}
|
||||
### {{ .Name }}
|
||||
|
||||
{{ if .Description }}{{ .Description }}{{ end }}
|
||||
|
||||
**Columns:**
|
||||
|
||||
| Column | Type | Nullable | PK | Description |
|
||||
|--------|------|----------|----|----|
|
||||
{{- range .Columns | values }}
|
||||
| {{ .Name }} | `{{ .Type }}` | {{ if .NotNull }}No{{ else }}Yes{{ end }} | {{ if .IsPrimaryKey }}✓{{ end }} | {{ .Description }} |
|
||||
{{- end }}
|
||||
|
||||
{{- $fks := filterForeignKeys .Constraints }}
|
||||
{{- if $fks }}
|
||||
|
||||
**Foreign Keys:**
|
||||
|
||||
{{ range $fks }}
|
||||
- `{{ .Name }}`: {{ join .Columns ", " }} → {{ .ReferencedTable }}({{ join .ReferencedColumns ", " }})
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Example 3: Python SQLAlchemy Models (Table Mode)
|
||||
|
||||
**Template:** `python-model.tmpl`
|
||||
```python
|
||||
"""{{ .Table.Name | toPascalCase }} model for {{ .ParentDatabase.Name }}.{{ .ParentSchema.Name }}"""
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class {{ .Table.Name | toPascalCase }}(Base):
|
||||
"""{{ if .Table.Description }}{{ .Table.Description }}{{ else }}{{ .Table.Name }} table{{ end }}"""
|
||||
|
||||
__tablename__ = "{{ .Table.Name }}"
|
||||
__table_args__ = {"schema": "{{ .ParentSchema.Name }}"}
|
||||
|
||||
{{- range .Table.Columns | values }}
|
||||
{{ .Name }} = Column({{ sqlToPython .Type }}{{ if .IsPrimaryKey }}, primary_key=True{{ end }}{{ if .NotNull }}, nullable=False{{ end }})
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Example 4: GraphQL Schema (Schema Mode)
|
||||
|
||||
**Template:** `graphql-schema.tmpl`
|
||||
```graphql
|
||||
"""{{ .Schema.Name }} schema"""
|
||||
|
||||
{{ range .Schema.Tables }}
|
||||
type {{ .Name | toPascalCase }} {
|
||||
{{- range .Columns | values }}
|
||||
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type .NotNull | replace " | null" "" }}{{ if not .NotNull }}{{ end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
input {{ .Name | toPascalCase }}Input {
|
||||
{{- $cols := filterNotNull .Columns | filterPrimaryKeys }}
|
||||
{{- range $cols }}
|
||||
{{ .Name | toCamelCase }}: {{ sqlToTypeScript .Type true | replace " | null" "" }}!
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Example 5: SQL Migration (Database Mode)
|
||||
|
||||
**Template:** `migration.tmpl`
|
||||
```sql
|
||||
-- Migration for {{ .Database.Name }}
|
||||
-- Generated: {{ .Metadata.timestamp }}
|
||||
|
||||
BEGIN;
|
||||
|
||||
{{ range .Database.Schemas }}
|
||||
-- Schema: {{ .Name }}
|
||||
CREATE SCHEMA IF NOT EXISTS {{ .Name }};
|
||||
|
||||
{{ range .Tables }}
|
||||
CREATE TABLE {{ $.Database.Name }}.{{ .Schema }}.{{ .Name }} (
|
||||
{{- range $i, $col := .Columns | values }}
|
||||
{{- if $i }},{{ end }}
|
||||
{{ $col.Name }} {{ $col.Type }}{{ if $col.NotNull }} NOT NULL{{ end }}{{ if $col.Default }} DEFAULT {{ $col.Default }}{{ end }}
|
||||
{{- end }}
|
||||
);
|
||||
|
||||
{{- $pks := filterPrimaryKeys .Columns }}
|
||||
{{- if $pks }}
|
||||
|
||||
ALTER TABLE {{ $.Database.Name }}.{{ .Schema }}.{{ .Name }}
|
||||
ADD PRIMARY KEY ({{ range $i, $pk := $pks }}{{ if $i }}, {{ end }}{{ $pk.Name }}{{ end }});
|
||||
{{- end }}
|
||||
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Hyphen for Whitespace Control:**
|
||||
```
|
||||
{{- removes whitespace before
|
||||
-}} removes whitespace after
|
||||
```
|
||||
|
||||
2. **Store Intermediate Results:**
|
||||
```
|
||||
{{ $pks := filterPrimaryKeys .Table.Columns }}
|
||||
{{ if $pks }}...{{ end }}
|
||||
```
|
||||
|
||||
3. **Check Before Accessing:**
|
||||
```
|
||||
{{ if .Table.Description }}{{ .Table.Description }}{{ end }}
|
||||
```
|
||||
|
||||
4. **Use Safe Access for Maps:**
|
||||
```
|
||||
{{ getOr .Metadata "key" "default-value" }}
|
||||
```
|
||||
|
||||
5. **Iterate Map Values:**
|
||||
```
|
||||
{{ range .Table.Columns | values }}...{{ end }}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Error: "wrong type for value"**
|
||||
- Check function parameter order (e.g., `sqlToGo .Type .NotNull` not `.NotNull .Type`)
|
||||
|
||||
**Error: "can't evaluate field"**
|
||||
- Field doesn't exist on the object
|
||||
- Use `{{ if .Field }}` to check before accessing
|
||||
|
||||
**Empty Output:**
|
||||
- Check your mode matches your template expectations
|
||||
- Verify data exists (use `{{ .Database | toJSON }}` to inspect)
|
||||
|
||||
**Whitespace Issues:**
|
||||
- Use `{{-` and `-}}` to control whitespace
|
||||
- Run output through a formatter if needed
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Template Documentation](https://pkg.go.dev/text/template)
|
||||
- [RelSpec Documentation](../README.md)
|
||||
- [Model Structure Reference](../pkg/models/)
|
||||
- [Example Templates](../examples/templates/)
|
||||
9
go.mod
9
go.mod
@@ -3,23 +3,30 @@ 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
|
||||
golang.org/x/text v0.28.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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
|
||||
@@ -27,5 +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/text v0.28.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=
|
||||
|
||||
74
pkg/commontypes/csharp.go
Normal file
74
pkg/commontypes/csharp.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package commontypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// CSharpTypeMap maps PostgreSQL types to C# types
|
||||
var CSharpTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "int",
|
||||
"int": "int",
|
||||
"int4": "int",
|
||||
"smallint": "short",
|
||||
"int2": "short",
|
||||
"bigint": "long",
|
||||
"int8": "long",
|
||||
"serial": "int",
|
||||
"bigserial": "long",
|
||||
"smallserial": "short",
|
||||
|
||||
// String types
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"citext": "string",
|
||||
"bpchar": "string",
|
||||
"uuid": "Guid",
|
||||
|
||||
// Boolean
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
|
||||
// Float types
|
||||
"real": "float",
|
||||
"float4": "float",
|
||||
"double precision": "double",
|
||||
"float8": "double",
|
||||
"numeric": "decimal",
|
||||
"decimal": "decimal",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "DateTime",
|
||||
"timestamp without time zone": "DateTime",
|
||||
"timestamp with time zone": "DateTimeOffset",
|
||||
"timestamptz": "DateTimeOffset",
|
||||
"date": "DateTime",
|
||||
"time": "TimeSpan",
|
||||
"time without time zone": "TimeSpan",
|
||||
"time with time zone": "DateTimeOffset",
|
||||
"timetz": "DateTimeOffset",
|
||||
|
||||
// Binary
|
||||
"bytea": "byte[]",
|
||||
|
||||
// JSON
|
||||
"json": "string",
|
||||
"jsonb": "string",
|
||||
}
|
||||
|
||||
// SQLToCSharp converts SQL types to C# types
|
||||
func SQLToCSharp(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
csType, ok := CSharpTypeMap[baseType]
|
||||
if !ok {
|
||||
csType = "object"
|
||||
}
|
||||
|
||||
// Handle nullable value types (reference types are already nullable)
|
||||
if !nullable && csType != "string" && !strings.HasSuffix(csType, "[]") && csType != "object" {
|
||||
return csType + "?"
|
||||
}
|
||||
|
||||
return csType
|
||||
}
|
||||
89
pkg/commontypes/golang.go
Normal file
89
pkg/commontypes/golang.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package commontypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// GoTypeMap maps PostgreSQL types to Go types
|
||||
var GoTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "int32",
|
||||
"int": "int32",
|
||||
"int4": "int32",
|
||||
"smallint": "int16",
|
||||
"int2": "int16",
|
||||
"bigint": "int64",
|
||||
"int8": "int64",
|
||||
"serial": "int32",
|
||||
"bigserial": "int64",
|
||||
"smallserial": "int16",
|
||||
|
||||
// String types
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"citext": "string",
|
||||
"bpchar": "string",
|
||||
|
||||
// Boolean
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
|
||||
// Float types
|
||||
"real": "float32",
|
||||
"float4": "float32",
|
||||
"double precision": "float64",
|
||||
"float8": "float64",
|
||||
"numeric": "float64",
|
||||
"decimal": "float64",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "time.Time",
|
||||
"timestamp without time zone": "time.Time",
|
||||
"timestamp with time zone": "time.Time",
|
||||
"timestamptz": "time.Time",
|
||||
"date": "time.Time",
|
||||
"time": "time.Time",
|
||||
"time without time zone": "time.Time",
|
||||
"time with time zone": "time.Time",
|
||||
"timetz": "time.Time",
|
||||
|
||||
// Binary
|
||||
"bytea": "[]byte",
|
||||
|
||||
// UUID
|
||||
"uuid": "string",
|
||||
|
||||
// JSON
|
||||
"json": "string",
|
||||
"jsonb": "string",
|
||||
|
||||
// Array
|
||||
"array": "[]string",
|
||||
}
|
||||
|
||||
// SQLToGo converts SQL types to Go types
|
||||
func SQLToGo(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
goType, ok := GoTypeMap[baseType]
|
||||
if !ok {
|
||||
goType = "interface{}"
|
||||
}
|
||||
|
||||
// Handle nullable types
|
||||
if nullable {
|
||||
return goType
|
||||
}
|
||||
|
||||
// For nullable, use pointer types (except for slices and interfaces)
|
||||
if !strings.HasPrefix(goType, "[]") && goType != "interface{}" {
|
||||
return "*" + goType
|
||||
}
|
||||
|
||||
return goType
|
||||
}
|
||||
|
||||
// NeedsTimeImport checks if a Go type requires the time package
|
||||
func NeedsTimeImport(goType string) bool {
|
||||
return strings.Contains(goType, "time.Time")
|
||||
}
|
||||
68
pkg/commontypes/java.go
Normal file
68
pkg/commontypes/java.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package commontypes
|
||||
|
||||
// JavaTypeMap maps PostgreSQL types to Java types
|
||||
var JavaTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "Integer",
|
||||
"int": "Integer",
|
||||
"int4": "Integer",
|
||||
"smallint": "Short",
|
||||
"int2": "Short",
|
||||
"bigint": "Long",
|
||||
"int8": "Long",
|
||||
"serial": "Integer",
|
||||
"bigserial": "Long",
|
||||
"smallserial": "Short",
|
||||
|
||||
// String types
|
||||
"text": "String",
|
||||
"varchar": "String",
|
||||
"char": "String",
|
||||
"character": "String",
|
||||
"citext": "String",
|
||||
"bpchar": "String",
|
||||
"uuid": "UUID",
|
||||
|
||||
// Boolean
|
||||
"boolean": "Boolean",
|
||||
"bool": "Boolean",
|
||||
|
||||
// Float types
|
||||
"real": "Float",
|
||||
"float4": "Float",
|
||||
"double precision": "Double",
|
||||
"float8": "Double",
|
||||
"numeric": "BigDecimal",
|
||||
"decimal": "BigDecimal",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "Timestamp",
|
||||
"timestamp without time zone": "Timestamp",
|
||||
"timestamp with time zone": "Timestamp",
|
||||
"timestamptz": "Timestamp",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"time without time zone": "Time",
|
||||
"time with time zone": "Time",
|
||||
"timetz": "Time",
|
||||
|
||||
// Binary
|
||||
"bytea": "byte[]",
|
||||
|
||||
// JSON
|
||||
"json": "String",
|
||||
"jsonb": "String",
|
||||
}
|
||||
|
||||
// SQLToJava converts SQL types to Java types
|
||||
func SQLToJava(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
javaType, ok := JavaTypeMap[baseType]
|
||||
if !ok {
|
||||
javaType = "Object"
|
||||
}
|
||||
|
||||
// Java uses wrapper classes for nullable types by default
|
||||
return javaType
|
||||
}
|
||||
72
pkg/commontypes/php.go
Normal file
72
pkg/commontypes/php.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commontypes
|
||||
|
||||
// PHPTypeMap maps PostgreSQL types to PHP types
|
||||
var PHPTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "int",
|
||||
"int": "int",
|
||||
"int4": "int",
|
||||
"smallint": "int",
|
||||
"int2": "int",
|
||||
"bigint": "int",
|
||||
"int8": "int",
|
||||
"serial": "int",
|
||||
"bigserial": "int",
|
||||
"smallserial": "int",
|
||||
|
||||
// String types
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"citext": "string",
|
||||
"bpchar": "string",
|
||||
"uuid": "string",
|
||||
|
||||
// Boolean
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
|
||||
// Float types
|
||||
"real": "float",
|
||||
"float4": "float",
|
||||
"double precision": "float",
|
||||
"float8": "float",
|
||||
"numeric": "float",
|
||||
"decimal": "float",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "\\DateTime",
|
||||
"timestamp without time zone": "\\DateTime",
|
||||
"timestamp with time zone": "\\DateTime",
|
||||
"timestamptz": "\\DateTime",
|
||||
"date": "\\DateTime",
|
||||
"time": "\\DateTime",
|
||||
"time without time zone": "\\DateTime",
|
||||
"time with time zone": "\\DateTime",
|
||||
"timetz": "\\DateTime",
|
||||
|
||||
// Binary
|
||||
"bytea": "string",
|
||||
|
||||
// JSON
|
||||
"json": "array",
|
||||
"jsonb": "array",
|
||||
}
|
||||
|
||||
// SQLToPhp converts SQL types to PHP types
|
||||
func SQLToPhp(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
phpType, ok := PHPTypeMap[baseType]
|
||||
if !ok {
|
||||
phpType = "mixed"
|
||||
}
|
||||
|
||||
// PHP 7.1+ supports nullable types with ?Type syntax
|
||||
if !nullable && phpType != "mixed" {
|
||||
return "?" + phpType
|
||||
}
|
||||
|
||||
return phpType
|
||||
}
|
||||
71
pkg/commontypes/python.go
Normal file
71
pkg/commontypes/python.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package commontypes
|
||||
|
||||
// PythonTypeMap maps PostgreSQL types to Python types
|
||||
var PythonTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "int",
|
||||
"int": "int",
|
||||
"int4": "int",
|
||||
"smallint": "int",
|
||||
"int2": "int",
|
||||
"bigint": "int",
|
||||
"int8": "int",
|
||||
"serial": "int",
|
||||
"bigserial": "int",
|
||||
"smallserial": "int",
|
||||
|
||||
// String types
|
||||
"text": "str",
|
||||
"varchar": "str",
|
||||
"char": "str",
|
||||
"character": "str",
|
||||
"citext": "str",
|
||||
"bpchar": "str",
|
||||
"uuid": "UUID",
|
||||
|
||||
// Boolean
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
|
||||
// Float types
|
||||
"real": "float",
|
||||
"float4": "float",
|
||||
"double precision": "float",
|
||||
"float8": "float",
|
||||
"numeric": "Decimal",
|
||||
"decimal": "Decimal",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "datetime",
|
||||
"timestamp without time zone": "datetime",
|
||||
"timestamp with time zone": "datetime",
|
||||
"timestamptz": "datetime",
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "time",
|
||||
"timetz": "time",
|
||||
|
||||
// Binary
|
||||
"bytea": "bytes",
|
||||
|
||||
// JSON
|
||||
"json": "dict",
|
||||
"jsonb": "dict",
|
||||
|
||||
// Array
|
||||
"array": "list",
|
||||
}
|
||||
|
||||
// SQLToPython converts SQL types to Python types
|
||||
func SQLToPython(sqlType string) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
pyType, ok := PythonTypeMap[baseType]
|
||||
if !ok {
|
||||
pyType = "Any"
|
||||
}
|
||||
|
||||
// Python uses Optional[Type] for nullable, but we return the base type
|
||||
return pyType
|
||||
}
|
||||
72
pkg/commontypes/rust.go
Normal file
72
pkg/commontypes/rust.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commontypes
|
||||
|
||||
// RustTypeMap maps PostgreSQL types to Rust types
|
||||
var RustTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "i32",
|
||||
"int": "i32",
|
||||
"int4": "i32",
|
||||
"smallint": "i16",
|
||||
"int2": "i16",
|
||||
"bigint": "i64",
|
||||
"int8": "i64",
|
||||
"serial": "i32",
|
||||
"bigserial": "i64",
|
||||
"smallserial": "i16",
|
||||
|
||||
// String types
|
||||
"text": "String",
|
||||
"varchar": "String",
|
||||
"char": "String",
|
||||
"character": "String",
|
||||
"citext": "String",
|
||||
"bpchar": "String",
|
||||
"uuid": "String",
|
||||
|
||||
// Boolean
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
|
||||
// Float types
|
||||
"real": "f32",
|
||||
"float4": "f32",
|
||||
"double precision": "f64",
|
||||
"float8": "f64",
|
||||
"numeric": "f64",
|
||||
"decimal": "f64",
|
||||
|
||||
// Date/Time types (using chrono crate)
|
||||
"timestamp": "NaiveDateTime",
|
||||
"timestamp without time zone": "NaiveDateTime",
|
||||
"timestamp with time zone": "DateTime<Utc>",
|
||||
"timestamptz": "DateTime<Utc>",
|
||||
"date": "NaiveDate",
|
||||
"time": "NaiveTime",
|
||||
"time without time zone": "NaiveTime",
|
||||
"time with time zone": "DateTime<Utc>",
|
||||
"timetz": "DateTime<Utc>",
|
||||
|
||||
// Binary
|
||||
"bytea": "Vec<u8>",
|
||||
|
||||
// JSON
|
||||
"json": "serde_json::Value",
|
||||
"jsonb": "serde_json::Value",
|
||||
}
|
||||
|
||||
// SQLToRust converts SQL types to Rust types
|
||||
func SQLToRust(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
rustType, ok := RustTypeMap[baseType]
|
||||
if !ok {
|
||||
rustType = "String"
|
||||
}
|
||||
|
||||
// Handle nullable types with Option<T>
|
||||
if nullable {
|
||||
return rustType
|
||||
}
|
||||
|
||||
return "Option<" + rustType + ">"
|
||||
}
|
||||
22
pkg/commontypes/sql.go
Normal file
22
pkg/commontypes/sql.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package commontypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// ExtractBaseType extracts the base type from a SQL type string
|
||||
// Examples: varchar(100) → varchar, numeric(10,2) → numeric
|
||||
func ExtractBaseType(sqlType string) string {
|
||||
sqlType = strings.ToLower(strings.TrimSpace(sqlType))
|
||||
|
||||
// Remove everything after '('
|
||||
if idx := strings.Index(sqlType, "("); idx > 0 {
|
||||
sqlType = sqlType[:idx]
|
||||
}
|
||||
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// NormalizeType normalizes a SQL type to its base form
|
||||
// Alias for ExtractBaseType for backwards compatibility
|
||||
func NormalizeType(sqlType string) string {
|
||||
return ExtractBaseType(sqlType)
|
||||
}
|
||||
75
pkg/commontypes/typescript.go
Normal file
75
pkg/commontypes/typescript.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package commontypes
|
||||
|
||||
// TypeScriptTypeMap maps PostgreSQL types to TypeScript types
|
||||
var TypeScriptTypeMap = map[string]string{
|
||||
// Integer types
|
||||
"integer": "number",
|
||||
"int": "number",
|
||||
"int4": "number",
|
||||
"smallint": "number",
|
||||
"int2": "number",
|
||||
"bigint": "number",
|
||||
"int8": "number",
|
||||
"serial": "number",
|
||||
"bigserial": "number",
|
||||
"smallserial": "number",
|
||||
|
||||
// String types
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"citext": "string",
|
||||
"bpchar": "string",
|
||||
"uuid": "string",
|
||||
|
||||
// Boolean
|
||||
"boolean": "boolean",
|
||||
"bool": "boolean",
|
||||
|
||||
// Float types
|
||||
"real": "number",
|
||||
"float4": "number",
|
||||
"double precision": "number",
|
||||
"float8": "number",
|
||||
"numeric": "number",
|
||||
"decimal": "number",
|
||||
|
||||
// Date/Time types
|
||||
"timestamp": "Date",
|
||||
"timestamp without time zone": "Date",
|
||||
"timestamp with time zone": "Date",
|
||||
"timestamptz": "Date",
|
||||
"date": "Date",
|
||||
"time": "Date",
|
||||
"time without time zone": "Date",
|
||||
"time with time zone": "Date",
|
||||
"timetz": "Date",
|
||||
|
||||
// Binary
|
||||
"bytea": "Buffer",
|
||||
|
||||
// JSON
|
||||
"json": "any",
|
||||
"jsonb": "any",
|
||||
|
||||
// Array
|
||||
"array": "any[]",
|
||||
}
|
||||
|
||||
// SQLToTypeScript converts SQL types to TypeScript types
|
||||
func SQLToTypeScript(sqlType string, nullable bool) string {
|
||||
baseType := ExtractBaseType(sqlType)
|
||||
|
||||
tsType, ok := TypeScriptTypeMap[baseType]
|
||||
if !ok {
|
||||
tsType = "any"
|
||||
}
|
||||
|
||||
// Handle nullable types
|
||||
if nullable {
|
||||
return tsType
|
||||
}
|
||||
|
||||
return tsType + " | null"
|
||||
}
|
||||
177
pkg/inspector/.relspec-rules.yaml.example
Normal file
177
pkg/inspector/.relspec-rules.yaml.example
Normal file
@@ -0,0 +1,177 @@
|
||||
# RelSpec Inspector Rules Configuration Example
|
||||
# Copy this file to .relspec-rules.yaml and customize as needed
|
||||
|
||||
version: "1.0"
|
||||
|
||||
rules:
|
||||
# ============================================================================
|
||||
# PRIMARY KEY RULES
|
||||
# ============================================================================
|
||||
|
||||
# Validate primary key column naming convention
|
||||
primary_key_naming:
|
||||
enabled: warn # enforce|warn|off
|
||||
function: primary_key_naming
|
||||
pattern: "^id_" # Regex pattern - PK columns must start with "id_"
|
||||
message: "Primary key columns should start with 'id_'"
|
||||
|
||||
# Validate primary key data types
|
||||
primary_key_datatype:
|
||||
enabled: warn
|
||||
function: primary_key_datatype
|
||||
allowed_types:
|
||||
- bigserial
|
||||
- bigint
|
||||
- int
|
||||
- serial
|
||||
- integer
|
||||
- int4
|
||||
- int8
|
||||
message: "Primary keys should use integer types (bigserial, bigint, int, serial)"
|
||||
|
||||
# Check if primary keys have auto-increment enabled
|
||||
primary_key_auto_increment:
|
||||
enabled: off # Often disabled as not all PKs need auto-increment
|
||||
function: primary_key_auto_increment
|
||||
require_auto_increment: true
|
||||
message: "Primary key without auto-increment detected"
|
||||
|
||||
# ============================================================================
|
||||
# FOREIGN KEY RULES
|
||||
# ============================================================================
|
||||
|
||||
# Validate foreign key column naming convention
|
||||
foreign_key_column_naming:
|
||||
enabled: warn
|
||||
function: foreign_key_column_naming
|
||||
pattern: "^rid_" # FK columns must start with "rid_" (referenced id)
|
||||
message: "Foreign key columns should start with 'rid_'"
|
||||
|
||||
# Validate foreign key constraint naming convention
|
||||
foreign_key_constraint_naming:
|
||||
enabled: warn
|
||||
function: foreign_key_constraint_naming
|
||||
pattern: "^fk_" # FK constraints must start with "fk_"
|
||||
message: "Foreign key constraint names should start with 'fk_'"
|
||||
|
||||
# Ensure foreign key columns have indexes for performance
|
||||
foreign_key_index:
|
||||
enabled: warn
|
||||
function: foreign_key_index
|
||||
require_index: true
|
||||
message: "Foreign key columns should have indexes for optimal performance"
|
||||
|
||||
# ============================================================================
|
||||
# NAMING CONVENTION RULES
|
||||
# ============================================================================
|
||||
|
||||
# Validate table naming follows snake_case convention
|
||||
table_naming_case:
|
||||
enabled: warn
|
||||
function: table_regexpr # Generic regex validator for table names
|
||||
case: lowercase
|
||||
pattern: "^[a-z][a-z0-9_]*$" # Lowercase letters, numbers, underscores only
|
||||
message: "Table names should be lowercase with underscores (snake_case)"
|
||||
|
||||
# Validate column naming follows snake_case convention
|
||||
column_naming_case:
|
||||
enabled: warn
|
||||
function: column_regexpr # Generic regex validator for column names
|
||||
case: lowercase
|
||||
pattern: "^[a-z][a-z0-9_]*$" # Lowercase letters, numbers, underscores only
|
||||
message: "Column names should be lowercase with underscores (snake_case)"
|
||||
|
||||
# ============================================================================
|
||||
# LENGTH RULES
|
||||
# ============================================================================
|
||||
|
||||
# Limit table name length (PostgreSQL max is 63, but 64 is common practice)
|
||||
table_name_length:
|
||||
enabled: warn
|
||||
function: table_name_length
|
||||
max_length: 64
|
||||
message: "Table name exceeds recommended maximum length of 64 characters"
|
||||
|
||||
# Limit column name length
|
||||
column_name_length:
|
||||
enabled: warn
|
||||
function: column_name_length
|
||||
max_length: 64
|
||||
message: "Column name exceeds recommended maximum length of 64 characters"
|
||||
|
||||
# ============================================================================
|
||||
# RESERVED KEYWORDS
|
||||
# ============================================================================
|
||||
|
||||
# Warn about using SQL reserved keywords as identifiers
|
||||
reserved_keywords:
|
||||
enabled: warn
|
||||
function: reserved_words
|
||||
check_tables: true
|
||||
check_columns: true
|
||||
message: "Using SQL reserved keywords as identifiers can cause issues"
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA INTEGRITY RULES
|
||||
# ============================================================================
|
||||
|
||||
# Ensure all tables have primary keys
|
||||
missing_primary_key:
|
||||
enabled: warn
|
||||
function: have_primary_key
|
||||
message: "Table is missing a primary key"
|
||||
|
||||
# Detect orphaned foreign keys (referencing non-existent tables)
|
||||
orphaned_foreign_key:
|
||||
enabled: warn
|
||||
function: orphaned_foreign_key
|
||||
message: "Foreign key references a non-existent table"
|
||||
|
||||
# Detect circular foreign key dependencies
|
||||
circular_dependency:
|
||||
enabled: warn
|
||||
function: circular_dependency
|
||||
message: "Circular foreign key dependency detected"
|
||||
|
||||
# ============================================================================
|
||||
# RULE CONFIGURATION NOTES
|
||||
# ============================================================================
|
||||
#
|
||||
# enabled: Controls rule enforcement level
|
||||
# - enforce: Violations are errors (exit code 1)
|
||||
# - warn: Violations are warnings (exit code 0)
|
||||
# - off: Rule is disabled
|
||||
#
|
||||
# function: The validation function to execute
|
||||
# - Must match a registered validator function
|
||||
# - Generic functions like table_regexpr and column_regexpr can be reused
|
||||
#
|
||||
# pattern: Regular expression for pattern matching
|
||||
# - Used by naming validators
|
||||
# - Must be valid Go regex syntax
|
||||
#
|
||||
# message: Custom message shown when rule is violated
|
||||
# - Should be clear and actionable
|
||||
# - Explains what the violation is and how to fix it
|
||||
#
|
||||
# ============================================================================
|
||||
# CUSTOM RULES EXAMPLES
|
||||
# ============================================================================
|
||||
#
|
||||
# You can add custom rules using the generic validator functions:
|
||||
#
|
||||
# # Example: Ensure table names don't contain numbers
|
||||
# table_no_numbers:
|
||||
# enabled: warn
|
||||
# function: table_regexpr
|
||||
# pattern: "^[a-z_]+$"
|
||||
# message: "Table names should not contain numbers"
|
||||
#
|
||||
# # Example: Audit columns must end with _audit
|
||||
# audit_column_suffix:
|
||||
# enabled: enforce
|
||||
# function: column_regexpr
|
||||
# pattern: ".*_audit$"
|
||||
# message: "Audit columns must end with '_audit'"
|
||||
#
|
||||
# ============================================================================
|
||||
472
pkg/inspector/PLAN.md
Normal file
472
pkg/inspector/PLAN.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Inspector Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add a model inspection feature that validates database schemas against configurable rules. The inspector will read any supported format, apply validation rules from a YAML config, and output a report in markdown or JSON format.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **CLI Command** (`cmd/relspec/inspect.go`)
|
||||
- New subcommand: `relspec inspect`
|
||||
- Flags:
|
||||
- `--from` (required): Input format (dbml, pgsql, json, etc.)
|
||||
- `--from-path`: File path for file-based formats
|
||||
- `--from-conn`: Connection string for database formats
|
||||
- `--rules` (optional): Path to rules YAML file (default: `.relspec-rules.yaml`)
|
||||
- `--output-format`: Report format (markdown, json) (default: markdown)
|
||||
- `--output`: Output file path (default: stdout)
|
||||
- `--schema`: Schema name filter (optional)
|
||||
|
||||
2. **Inspector Package** (`pkg/inspector/`)
|
||||
- `inspector.go`: Main inspector logic
|
||||
- `rules.go`: Rule definitions and configuration
|
||||
- `validators.go`: Individual validation rule implementations
|
||||
- `report.go`: Report generation (markdown, JSON)
|
||||
- `config.go`: YAML config loading and parsing
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
Input Format → Reader → Database Model → Inspector → Validation Results → Report Formatter → Output
|
||||
```
|
||||
|
||||
## Rules Configuration Structure
|
||||
|
||||
### YAML Schema (`rules.yaml`)
|
||||
```yaml
|
||||
version: "1.0"
|
||||
rules:
|
||||
# Primary Key Rules
|
||||
primary_key_naming:
|
||||
enabled: enforce|warn|off
|
||||
pattern: "^id_" # regex pattern
|
||||
message: "Primary key columns must start with 'id_'"
|
||||
|
||||
primary_key_datatype:
|
||||
enabled: enforce|warn|off
|
||||
allowed_types: ["bigserial", "bigint", "int", "serial", "integer"]
|
||||
message: "Primary keys must use approved integer types"
|
||||
|
||||
primary_key_auto_increment:
|
||||
enabled: enforce|warn|off
|
||||
require_auto_increment: true|false
|
||||
message: "Primary keys without auto-increment detected"
|
||||
|
||||
# Foreign Key Rules
|
||||
foreign_key_column_naming:
|
||||
enabled: enforce|warn|off
|
||||
pattern: "^rid_"
|
||||
message: "Foreign key columns must start with 'rid_'"
|
||||
|
||||
foreign_key_constraint_naming:
|
||||
enabled: enforce|warn|off
|
||||
pattern: "^fk_"
|
||||
message: "Foreign key constraint names must start with 'fk_'"
|
||||
|
||||
foreign_key_index:
|
||||
enabled: enforce|warn|off
|
||||
require_index: true
|
||||
message: "Foreign keys should have indexes"
|
||||
|
||||
# Naming Convention Rules
|
||||
table_naming_case:
|
||||
enabled: enforce|warn|off
|
||||
case: "lowercase" # lowercase, uppercase, snake_case, camelCase
|
||||
pattern: "^[a-z][a-z0-9_]*$"
|
||||
message: "Table names must be lowercase with underscores"
|
||||
|
||||
column_naming_case:
|
||||
enabled: enforce|warn|off
|
||||
case: "lowercase"
|
||||
pattern: "^[a-z][a-z0-9_]*$"
|
||||
message: "Column names must be lowercase with underscores"
|
||||
|
||||
# Length Rules
|
||||
table_name_length:
|
||||
enabled: enforce|warn|off
|
||||
max_length: 64
|
||||
message: "Table name exceeds maximum length"
|
||||
|
||||
column_name_length:
|
||||
enabled: enforce|warn|off
|
||||
max_length: 64
|
||||
message: "Column name exceeds maximum length"
|
||||
|
||||
# Reserved Keywords
|
||||
reserved_keywords:
|
||||
enabled: enforce|warn|off
|
||||
check_tables: true
|
||||
check_columns: true
|
||||
message: "Using reserved SQL keywords"
|
||||
|
||||
# Schema Integrity Rules
|
||||
missing_primary_key:
|
||||
enabled: enforce|warn|off
|
||||
message: "Table missing primary key"
|
||||
|
||||
orphaned_foreign_key:
|
||||
enabled: enforce|warn|off
|
||||
message: "Foreign key references non-existent table"
|
||||
|
||||
circular_dependency:
|
||||
enabled: enforce|warn|off
|
||||
message: "Circular foreign key dependency detected"
|
||||
```
|
||||
|
||||
### Rule Levels
|
||||
- **enforce**: Violations are errors (exit code 1)
|
||||
- **warn**: Violations are warnings (exit code 0)
|
||||
- **off**: Rule disabled
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Inspector Core (`pkg/inspector/inspector.go`)
|
||||
|
||||
```go
|
||||
type Inspector struct {
|
||||
config *Config
|
||||
db *models.Database
|
||||
}
|
||||
|
||||
type ValidationResult struct {
|
||||
RuleName string
|
||||
Level string // "error" or "warning"
|
||||
Message string
|
||||
Location string // e.g., "schema.table.column"
|
||||
Context map[string]interface{}
|
||||
Passed bool
|
||||
}
|
||||
|
||||
type InspectorReport struct {
|
||||
Summary ReportSummary
|
||||
Violations []ValidationResult
|
||||
GeneratedAt time.Time
|
||||
Database string
|
||||
SourceFormat string
|
||||
}
|
||||
|
||||
type ReportSummary struct {
|
||||
TotalRules int
|
||||
RulesChecked int
|
||||
RulesSkipped int
|
||||
ErrorCount int
|
||||
WarningCount int
|
||||
PassedCount int
|
||||
}
|
||||
|
||||
func NewInspector(db *models.Database, config *Config) *Inspector
|
||||
func (i *Inspector) Inspect() (*InspectorReport, error)
|
||||
func (i *Inspector) validateDatabase() []ValidationResult
|
||||
func (i *Inspector) validateSchema(schema *models.Schema) []ValidationResult
|
||||
func (i *Inspector) validateTable(table *models.Table) []ValidationResult
|
||||
```
|
||||
|
||||
### 2. Rule Definitions (`pkg/inspector/rules.go`)
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Version string
|
||||
Rules map[string]Rule
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
Enabled string // "enforce", "warn", "off"
|
||||
Message string
|
||||
Pattern string
|
||||
AllowedTypes []string
|
||||
MaxLength int
|
||||
Case string
|
||||
RequireIndex bool
|
||||
CheckTables bool
|
||||
CheckColumns bool
|
||||
// ... rule-specific fields
|
||||
}
|
||||
|
||||
type RuleValidator interface {
|
||||
Name() string
|
||||
Validate(db *models.Database, rule Rule) []ValidationResult
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error)
|
||||
func GetDefaultConfig() *Config
|
||||
```
|
||||
|
||||
**Configuration Loading Behavior:**
|
||||
- If `--rules` flag is provided but file not found: Use default configuration (don't error)
|
||||
- If file exists but is invalid YAML: Return error
|
||||
- Default configuration has sensible rules enabled at "warn" level
|
||||
- Users can override by creating their own `.relspec-rules.yaml` file
|
||||
|
||||
### 3. Validators (`pkg/inspector/validators.go`)
|
||||
|
||||
Each validator implements rule logic:
|
||||
|
||||
```go
|
||||
// Primary Key Validators
|
||||
func validatePrimaryKeyNaming(db *models.Database, rule Rule) []ValidationResult
|
||||
func validatePrimaryKeyDatatype(db *models.Database, rule Rule) []ValidationResult
|
||||
func validatePrimaryKeyAutoIncrement(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Foreign Key Validators
|
||||
func validateForeignKeyColumnNaming(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateForeignKeyConstraintNaming(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateForeignKeyIndex(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Naming Convention Validators
|
||||
func validateTableNamingCase(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateColumnNamingCase(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Length Validators
|
||||
func validateTableNameLength(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateColumnNameLength(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Reserved Keywords Validator
|
||||
func validateReservedKeywords(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Integrity Validators
|
||||
func validateMissingPrimaryKey(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateOrphanedForeignKey(db *models.Database, rule Rule) []ValidationResult
|
||||
func validateCircularDependency(db *models.Database, rule Rule) []ValidationResult
|
||||
|
||||
// Registry of all validators
|
||||
var validators = map[string]RuleValidator{
|
||||
"primary_key_naming": primaryKeyNamingValidator{},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Report Formatting (`pkg/inspector/report.go`)
|
||||
|
||||
```go
|
||||
type ReportFormatter interface {
|
||||
Format(report *InspectorReport) (string, error)
|
||||
}
|
||||
|
||||
type MarkdownFormatter struct {
|
||||
UseColors bool // ANSI colors for terminal output
|
||||
}
|
||||
type JSONFormatter struct{}
|
||||
|
||||
func (f *MarkdownFormatter) Format(report *InspectorReport) (string, error)
|
||||
func (f *JSONFormatter) Format(report *InspectorReport) (string, error)
|
||||
|
||||
// Helper to detect if output is a TTY (terminal)
|
||||
func isTerminal(w io.Writer) bool
|
||||
```
|
||||
|
||||
**Output Behavior:**
|
||||
- Markdown format will use ANSI color codes when outputting to a terminal (TTY)
|
||||
- When piped or redirected to a file, plain markdown without colors
|
||||
- Colors: Red for errors, Yellow for warnings, Green for passed checks
|
||||
|
||||
**Markdown Format Example:**
|
||||
```markdown
|
||||
# RelSpec Inspector Report
|
||||
|
||||
**Database:** my_database
|
||||
**Source Format:** pgsql
|
||||
**Generated:** 2025-12-31 10:30:45
|
||||
|
||||
## Summary
|
||||
- Rules Checked: 12
|
||||
- Errors: 3
|
||||
- Warnings: 5
|
||||
- Passed: 4
|
||||
|
||||
## Violations
|
||||
|
||||
### Errors (3)
|
||||
|
||||
#### primary_key_naming
|
||||
**Table:** users, **Column:** user_id
|
||||
Primary key columns must start with 'id_'
|
||||
|
||||
#### table_name_length
|
||||
**Table:** user_authentication_sessions_with_metadata
|
||||
Table name exceeds maximum length (64 characters)
|
||||
|
||||
### Warnings (5)
|
||||
|
||||
#### foreign_key_index
|
||||
**Table:** orders, **Column:** customer_id
|
||||
Foreign keys should have indexes
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
**JSON Format Example:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_rules": 12,
|
||||
"rules_checked": 12,
|
||||
"error_count": 3,
|
||||
"warning_count": 5,
|
||||
"passed_count": 4
|
||||
},
|
||||
"violations": [
|
||||
{
|
||||
"rule_name": "primary_key_naming",
|
||||
"level": "error",
|
||||
"message": "Primary key columns must start with 'id_'",
|
||||
"location": "public.users.user_id",
|
||||
"context": {
|
||||
"schema": "public",
|
||||
"table": "users",
|
||||
"column": "user_id",
|
||||
"current_name": "user_id",
|
||||
"expected_pattern": "^id_"
|
||||
},
|
||||
"passed": false
|
||||
}
|
||||
],
|
||||
"generated_at": "2025-12-31T10:30:45Z",
|
||||
"database": "my_database",
|
||||
"source_format": "pgsql"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. CLI Command (`cmd/relspec/inspect.go`)
|
||||
|
||||
```go
|
||||
var inspectCmd = &cobra.Command{
|
||||
Use: "inspect",
|
||||
Short: "Inspect and validate database schemas against rules",
|
||||
Long: `Read database schemas from various formats and validate against configurable rules.`,
|
||||
RunE: runInspect,
|
||||
}
|
||||
|
||||
func init() {
|
||||
inspectCmd.Flags().String("from", "", "Input format (dbml, pgsql, json, etc.)")
|
||||
inspectCmd.Flags().String("from-path", "", "Input file path")
|
||||
inspectCmd.Flags().String("from-conn", "", "Database connection string")
|
||||
inspectCmd.Flags().String("rules", ".relspec-rules.yaml", "Rules configuration file")
|
||||
inspectCmd.Flags().String("output-format", "markdown", "Output format (markdown, json)")
|
||||
inspectCmd.Flags().String("output", "", "Output file (default: stdout)")
|
||||
inspectCmd.Flags().String("schema", "", "Filter by schema name")
|
||||
inspectCmd.MarkFlagRequired("from")
|
||||
}
|
||||
|
||||
func runInspect(cmd *cobra.Command, args []string) error {
|
||||
// 1. Parse flags
|
||||
// 2. Create reader (reuse pattern from convert.go)
|
||||
// 3. Read database
|
||||
// 4. Load rules config (use defaults if file not found)
|
||||
// 5. Create inspector
|
||||
// 6. Run inspection
|
||||
// 7. Detect if output is terminal (for color support)
|
||||
// 8. Format report (with/without ANSI colors)
|
||||
// 9. Write output
|
||||
// 10. Exit with appropriate code (0 if no errors, 1 if errors)
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
1. Create `pkg/inspector/` package structure
|
||||
2. Implement `Config` and YAML loading
|
||||
3. Implement `Inspector` core with basic validation framework
|
||||
4. Create CLI command skeleton
|
||||
|
||||
### Phase 2: Basic Validators
|
||||
1. Implement naming convention validators
|
||||
- Primary key naming
|
||||
- Foreign key column naming
|
||||
- Foreign key constraint naming
|
||||
- Table/column case validation
|
||||
2. Implement length validators
|
||||
3. Implement reserved keywords validator (leverage `pkg/pgsql/keywords.go`)
|
||||
|
||||
### Phase 3: Advanced Validators
|
||||
1. Implement datatype validators
|
||||
2. Implement integrity validators (missing PK, orphaned FK, circular deps)
|
||||
3. Implement foreign key index validator
|
||||
|
||||
### Phase 4: Reporting
|
||||
1. Implement `InspectorReport` structure
|
||||
2. Implement markdown formatter
|
||||
3. Implement JSON formatter
|
||||
4. Add summary statistics
|
||||
|
||||
### Phase 5: CLI Integration
|
||||
1. Wire up CLI command with flags
|
||||
2. Integrate reader factory (from convert.go pattern)
|
||||
3. Add output file handling
|
||||
4. Add exit code logic
|
||||
5. Add progress reporting
|
||||
|
||||
### Phase 6: Testing & Documentation
|
||||
1. Unit tests for validators
|
||||
2. Integration tests with sample schemas
|
||||
3. Test with all reader formats
|
||||
4. Update README with inspector documentation
|
||||
5. Create example rules configuration file
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `pkg/inspector/inspector.go` - Core inspector logic
|
||||
2. `pkg/inspector/rules.go` - Rule definitions and config loading
|
||||
3. `pkg/inspector/validators.go` - Validation implementations
|
||||
4. `pkg/inspector/report.go` - Report formatting
|
||||
5. `pkg/inspector/config.go` - Config utilities
|
||||
6. `cmd/relspec/inspect.go` - CLI command
|
||||
7. `.relspec-rules.yaml.example` - Example configuration
|
||||
8. `pkg/inspector/inspector_test.go` - Tests
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `cmd/relspec/root.go` - Register inspect command
|
||||
2. `README.md` - Add inspector documentation (if requested)
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Inspect a PostgreSQL database with default rules
|
||||
relspec inspect --from pgsql --from-conn "postgresql://localhost/mydb"
|
||||
|
||||
# Inspect a DBML file with custom rules
|
||||
relspec inspect --from dbml --from-path schema.dbml --rules my-rules.yaml
|
||||
|
||||
# Output JSON report to file
|
||||
relspec inspect --from json --from-path db.json --output-format json --output report.json
|
||||
|
||||
# Inspect specific schema only
|
||||
relspec inspect --from pgsql --from-conn "..." --schema public
|
||||
|
||||
# Use custom rules location
|
||||
relspec inspect --from dbml --from-path schema.dbml --rules /path/to/rules.yaml
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
- 0: Success (no errors, only warnings or all passed)
|
||||
- 1: Validation errors found (rules with level="enforce" failed)
|
||||
- 2: Runtime error (invalid config, reader error, etc.)
|
||||
|
||||
## Dependencies
|
||||
- Existing: `pkg/models`, `pkg/readers`, `pkg/pgsql/keywords.go`
|
||||
- New: `gopkg.in/yaml.v3` for YAML parsing (may already be in go.mod)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Confirmed Choices (from user)
|
||||
1. **Example config file**: Create `.relspec-rules.yaml.example` in repository root with documented examples
|
||||
2. **Missing rules file**: Use sensible built-in defaults (don't error), all rules at "warn" level by default
|
||||
3. **Terminal output**: ANSI colors (red/yellow/green) when outputting to terminal, plain markdown when piped/redirected
|
||||
4. **Foreign key naming**: Separate configurable rules for both FK column names and FK constraint names
|
||||
|
||||
### Architecture Rationale
|
||||
1. **Why YAML for config?**: Human-readable, supports comments, standard for config files
|
||||
2. **Why three levels (enforce/warn/off)?**: Flexibility for gradual adoption, different contexts
|
||||
3. **Why markdown + JSON?**: Markdown for human review, JSON for tooling integration
|
||||
4. **Why pkg/inspector?**: Follows existing package structure, separates concerns
|
||||
5. **Reuse readers**: Leverage existing reader infrastructure, supports all formats automatically
|
||||
6. **Exit codes**: Follow standard conventions (0=success, 1=validation fail, 2=error)
|
||||
|
||||
## Future Enhancements (Not in Scope)
|
||||
- Auto-fix mode (automatically rename columns, etc.)
|
||||
- Custom rule plugins
|
||||
- HTML report format
|
||||
- Rule templates for different databases
|
||||
- CI/CD integration examples
|
||||
- Performance metrics in report
|
||||
485
pkg/inspector/README.md
Normal file
485
pkg/inspector/README.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# RelSpec Inspector
|
||||
|
||||
> Database Schema Validation and Linting Tool
|
||||
|
||||
The RelSpec Inspector validates database schemas against configurable rules, helping you maintain consistency, enforce naming conventions, and catch common schema design issues across your database models.
|
||||
|
||||
## Overview
|
||||
|
||||
The Inspector reads database schemas from any supported RelSpec format and validates them against a set of configurable rules. It generates detailed reports highlighting violations, warnings, and passed checks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Flexible Rule Configuration**: YAML-based rules with three severity levels (enforce, warn, off)
|
||||
- **Generic Validators**: Reusable regex-based validators for custom naming conventions
|
||||
- **Multiple Input Formats**: Works with all RelSpec readers (PostgreSQL, DBML, JSON, GORM, Bun, etc.)
|
||||
- **Multiple Output Formats**: Markdown with ANSI colors for terminals, JSON for tooling integration
|
||||
- **Smart Defaults**: Works out-of-the-box with sensible default rules
|
||||
- **Terminal-Aware**: Automatic color support detection for improved readability
|
||||
- **Exit Codes**: Proper exit codes for CI/CD integration
|
||||
|
||||
[Todo List of Features](./TODO.md)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Inspect a PostgreSQL database with default rules
|
||||
relspec inspect --from pgsql --from-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Inspect a DBML file
|
||||
relspec inspect --from dbml --from-path schema.dbml
|
||||
|
||||
# Inspect with custom rules
|
||||
relspec inspect --from json --from-path db.json --rules my-rules.yaml
|
||||
|
||||
# Output JSON report to file
|
||||
relspec inspect --from pgsql --from-conn "..." \
|
||||
--output-format json --output report.json
|
||||
|
||||
# Inspect specific schema only
|
||||
relspec inspect --from pgsql --from-conn "..." --schema public
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.relspec-rules.yaml` file to customize validation rules. If the file doesn't exist, the inspector uses sensible defaults.
|
||||
|
||||
```yaml
|
||||
version: "1.0"
|
||||
|
||||
rules:
|
||||
# Primary key columns must start with "id_"
|
||||
primary_key_naming:
|
||||
enabled: enforce # enforce|warn|off
|
||||
function: primary_key_naming
|
||||
pattern: "^id_"
|
||||
message: "Primary key columns must start with 'id_'"
|
||||
|
||||
# Foreign key columns must start with "rid_"
|
||||
foreign_key_column_naming:
|
||||
enabled: warn
|
||||
function: foreign_key_column_naming
|
||||
pattern: "^rid_"
|
||||
message: "Foreign key columns should start with 'rid_'"
|
||||
|
||||
# Table names must be lowercase snake_case
|
||||
table_naming_case:
|
||||
enabled: warn
|
||||
function: table_regexpr # Generic regex validator
|
||||
pattern: "^[a-z][a-z0-9_]*$"
|
||||
message: "Table names should be lowercase with underscores"
|
||||
|
||||
# Ensure all tables have primary keys
|
||||
missing_primary_key:
|
||||
enabled: warn
|
||||
function: have_primary_key
|
||||
message: "Table is missing a primary key"
|
||||
```
|
||||
|
||||
## Built-in Validation Rules
|
||||
|
||||
### Primary Key Rules
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `primary_key_naming` | `primary_key_naming` | Validate PK column names against regex pattern |
|
||||
| `primary_key_datatype` | `primary_key_datatype` | Enforce approved PK data types (bigint, serial, etc.) |
|
||||
| `primary_key_auto_increment` | `primary_key_auto_increment` | Check if PKs have auto-increment enabled |
|
||||
|
||||
### Foreign Key Rules
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `foreign_key_column_naming` | `foreign_key_column_naming` | Validate FK column names against regex pattern |
|
||||
| `foreign_key_constraint_naming` | `foreign_key_constraint_naming` | Validate FK constraint names against regex pattern |
|
||||
| `foreign_key_index` | `foreign_key_index` | Ensure FK columns have indexes for performance |
|
||||
|
||||
### Naming Convention Rules
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `table_naming_case` | `table_regexpr` | Generic regex validator for table names |
|
||||
| `column_naming_case` | `column_regexpr` | Generic regex validator for column names |
|
||||
|
||||
### Length Rules
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `table_name_length` | `table_name_length` | Limit table name length (default: 64 chars) |
|
||||
| `column_name_length` | `column_name_length` | Limit column name length (default: 64 chars) |
|
||||
|
||||
### Reserved Keywords
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `reserved_keywords` | `reserved_words` | Detect use of SQL reserved keywords as identifiers |
|
||||
|
||||
### Schema Integrity Rules
|
||||
|
||||
| Rule | Function | Description |
|
||||
|------|----------|-------------|
|
||||
| `missing_primary_key` | `have_primary_key` | Ensure tables have primary keys |
|
||||
| `orphaned_foreign_key` | `orphaned_foreign_key` | Detect FKs referencing non-existent tables |
|
||||
| `circular_dependency` | `circular_dependency` | Detect circular FK dependencies |
|
||||
|
||||
## Rule Configuration
|
||||
|
||||
### Severity Levels
|
||||
|
||||
Rules support three severity levels:
|
||||
|
||||
- **`enforce`**: Violations are errors (exit code 1)
|
||||
- **`warn`**: Violations are warnings (exit code 0)
|
||||
- **`off`**: Rule is disabled
|
||||
|
||||
### Rule Structure
|
||||
|
||||
```yaml
|
||||
rule_name:
|
||||
enabled: enforce|warn|off
|
||||
function: validator_function_name
|
||||
message: "Custom message shown on violation"
|
||||
# Rule-specific parameters
|
||||
pattern: "^regex_pattern$" # For pattern-based validators
|
||||
allowed_types: [type1, type2] # For type validators
|
||||
max_length: 64 # For length validators
|
||||
check_tables: true # For keyword validator
|
||||
check_columns: true # For keyword validator
|
||||
require_index: true # For FK index validator
|
||||
```
|
||||
|
||||
## Generic Validators
|
||||
|
||||
The inspector provides generic validator functions that can be reused for custom rules:
|
||||
|
||||
### `table_regexpr`
|
||||
|
||||
Generic regex validator for table names. Create custom table naming rules:
|
||||
|
||||
```yaml
|
||||
# Example: Ensure table names don't contain numbers
|
||||
table_no_numbers:
|
||||
enabled: warn
|
||||
function: table_regexpr
|
||||
pattern: "^[a-z_]+$"
|
||||
message: "Table names should not contain numbers"
|
||||
|
||||
# Example: Tables must start with "tbl_"
|
||||
table_prefix:
|
||||
enabled: enforce
|
||||
function: table_regexpr
|
||||
pattern: "^tbl_[a-z][a-z0-9_]*$"
|
||||
message: "Table names must start with 'tbl_'"
|
||||
```
|
||||
|
||||
### `column_regexpr`
|
||||
|
||||
Generic regex validator for column names. Create custom column naming rules:
|
||||
|
||||
```yaml
|
||||
# Example: Audit columns must end with "_audit"
|
||||
audit_column_suffix:
|
||||
enabled: enforce
|
||||
function: column_regexpr
|
||||
pattern: ".*_audit$"
|
||||
message: "Audit columns must end with '_audit'"
|
||||
|
||||
# Example: Timestamp columns must end with "_at"
|
||||
timestamp_suffix:
|
||||
enabled: warn
|
||||
function: column_regexpr
|
||||
pattern: ".*(created|updated|deleted)_at$"
|
||||
message: "Timestamp columns should end with '_at'"
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Markdown (Default)
|
||||
|
||||
Human-readable markdown report with ANSI colors when outputting to a terminal:
|
||||
|
||||
```
|
||||
# RelSpec Inspector Report
|
||||
|
||||
**Database:** my_database
|
||||
**Source Format:** pgsql
|
||||
**Generated:** 2025-12-31T10:30:45Z
|
||||
|
||||
## Summary
|
||||
- Rules Checked: 13
|
||||
- Errors: 2
|
||||
- Warnings: 5
|
||||
- Passed: 120
|
||||
|
||||
## Violations
|
||||
|
||||
### Errors (2)
|
||||
|
||||
#### primary_key_naming
|
||||
**Location:** public.users.user_id
|
||||
**Message:** Primary key columns must start with 'id_'
|
||||
**Details:** expected_pattern=^id_
|
||||
|
||||
### Warnings (5)
|
||||
|
||||
#### foreign_key_index
|
||||
**Location:** public.orders.customer_id
|
||||
**Message:** Foreign key columns should have indexes
|
||||
**Details:** has_index=false
|
||||
```
|
||||
|
||||
### JSON
|
||||
|
||||
Structured JSON output for tooling integration:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_rules": 13,
|
||||
"rules_checked": 13,
|
||||
"error_count": 2,
|
||||
"warning_count": 5,
|
||||
"passed_count": 120
|
||||
},
|
||||
"violations": [
|
||||
{
|
||||
"rule_name": "primary_key_naming",
|
||||
"level": "error",
|
||||
"message": "Primary key columns must start with 'id_'",
|
||||
"location": "public.users.user_id",
|
||||
"context": {
|
||||
"schema": "public",
|
||||
"table": "users",
|
||||
"column": "user_id",
|
||||
"expected_pattern": "^id_"
|
||||
},
|
||||
"passed": false
|
||||
}
|
||||
],
|
||||
"generated_at": "2025-12-31T10:30:45Z",
|
||||
"database": "my_database",
|
||||
"source_format": "pgsql"
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `--from` | string | **Required**. Source format (dbml, pgsql, json, yaml, gorm, etc.) |
|
||||
| `--from-path` | string | Source file path (for file-based formats) |
|
||||
| `--from-conn` | string | Connection string (for database formats) |
|
||||
| `--rules` | string | Path to rules YAML file (default: `.relspec-rules.yaml`) |
|
||||
| `--output-format` | string | Output format: `markdown` or `json` (default: `markdown`) |
|
||||
| `--output` | string | Output file path (default: stdout) |
|
||||
| `--schema` | string | Filter to specific schema by name |
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success (no errors, only warnings or all passed) |
|
||||
| 1 | Validation errors found (rules with `enabled: enforce` failed) |
|
||||
| 2 | Runtime error (invalid config, reader error, etc.) |
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Schema Validation
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install RelSpec
|
||||
run: go install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
|
||||
- name: Validate Schema
|
||||
run: |
|
||||
relspec inspect \
|
||||
--from dbml \
|
||||
--from-path schema.dbml \
|
||||
--rules .relspec-rules.yaml \
|
||||
--output-format json \
|
||||
--output validation-report.json
|
||||
|
||||
- name: Upload Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: validation-report
|
||||
path: validation-report.json
|
||||
```
|
||||
|
||||
### Pre-commit Hook Example
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-commit
|
||||
|
||||
echo "Running schema validation..."
|
||||
|
||||
relspec inspect \
|
||||
--from dbml \
|
||||
--from-path schema.dbml \
|
||||
--rules .relspec-rules.yaml
|
||||
|
||||
exit $?
|
||||
```
|
||||
|
||||
## Example Configuration File
|
||||
|
||||
See [`.relspec-rules.yaml.example`](../../.relspec-rules.yaml.example) for a fully documented example configuration with all available rules and customization options.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Enforce Naming Standards
|
||||
|
||||
```yaml
|
||||
# Ensure consistent naming across your schema
|
||||
table_naming_case:
|
||||
enabled: enforce
|
||||
function: table_regexpr
|
||||
pattern: "^[a-z][a-z0-9_]*$"
|
||||
message: "Tables must use snake_case"
|
||||
|
||||
column_naming_case:
|
||||
enabled: enforce
|
||||
function: column_regexpr
|
||||
pattern: "^[a-z][a-z0-9_]*$"
|
||||
message: "Columns must use snake_case"
|
||||
|
||||
primary_key_naming:
|
||||
enabled: enforce
|
||||
function: primary_key_naming
|
||||
pattern: "^id$"
|
||||
message: "Primary key must be named 'id'"
|
||||
|
||||
foreign_key_column_naming:
|
||||
enabled: enforce
|
||||
function: foreign_key_column_naming
|
||||
pattern: "^[a-z]+_id$"
|
||||
message: "Foreign keys must end with '_id'"
|
||||
```
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
```yaml
|
||||
# Ensure optimal database performance
|
||||
foreign_key_index:
|
||||
enabled: enforce
|
||||
function: foreign_key_index
|
||||
require_index: true
|
||||
message: "Foreign keys must have indexes"
|
||||
|
||||
primary_key_datatype:
|
||||
enabled: enforce
|
||||
function: primary_key_datatype
|
||||
allowed_types: [bigserial, bigint]
|
||||
message: "Use bigserial or bigint for primary keys"
|
||||
```
|
||||
|
||||
### Schema Integrity
|
||||
|
||||
```yaml
|
||||
# Prevent common schema issues
|
||||
missing_primary_key:
|
||||
enabled: enforce
|
||||
function: have_primary_key
|
||||
message: "All tables must have a primary key"
|
||||
|
||||
orphaned_foreign_key:
|
||||
enabled: enforce
|
||||
function: orphaned_foreign_key
|
||||
message: "Foreign keys must reference existing tables"
|
||||
|
||||
circular_dependency:
|
||||
enabled: warn
|
||||
function: circular_dependency
|
||||
message: "Circular dependencies detected"
|
||||
```
|
||||
|
||||
### Avoid Reserved Keywords
|
||||
|
||||
```yaml
|
||||
reserved_keywords:
|
||||
enabled: warn
|
||||
function: reserved_words
|
||||
check_tables: true
|
||||
check_columns: true
|
||||
message: "Avoid using SQL reserved keywords"
|
||||
```
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
You can use the inspector programmatically in your Go code:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/inspector"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load your database model
|
||||
db := &models.Database{
|
||||
Name: "my_database",
|
||||
Schemas: []*models.Schema{
|
||||
// ... your schema
|
||||
},
|
||||
}
|
||||
|
||||
// Load rules configuration
|
||||
config, err := inspector.LoadConfig(".relspec-rules.yaml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create and run inspector
|
||||
insp := inspector.NewInspector(db, config)
|
||||
report, err := insp.Inspect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Generate report
|
||||
formatter := inspector.NewMarkdownFormatter(os.Stdout)
|
||||
output, err := formatter.Format(report)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(output)
|
||||
|
||||
// Check for errors
|
||||
if report.HasErrors() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! To add a new validator:
|
||||
|
||||
1. Add the validator function to `validators.go`
|
||||
2. Register it in `inspector.go` `getValidator()` function
|
||||
3. Add default configuration to `rules.go` `GetDefaultConfig()`
|
||||
4. Update this README with the new rule documentation
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
65
pkg/inspector/TODO.md
Normal file
65
pkg/inspector/TODO.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Inspector TODO
|
||||
|
||||
See the [Inspector README](./README.md) for complete documentation of implemented features.
|
||||
|
||||
### Implemented ✓
|
||||
|
||||
- [x] Core validation framework with configurable rules
|
||||
- [x] YAML configuration with three severity levels (enforce/warn/off)
|
||||
- [x] Generic validators (table_regexpr, column_regexpr)
|
||||
- [x] Primary key validation (naming, datatype, auto-increment)
|
||||
- [x] Foreign key validation (column naming, constraint naming, indexes)
|
||||
- [x] Naming convention validation (snake_case, custom patterns)
|
||||
- [x] Length validation (table names, column names)
|
||||
- [x] Reserved keywords detection
|
||||
- [x] Schema integrity checks (missing PKs, orphaned FKs, circular dependencies)
|
||||
- [x] Multiple output formats (Markdown with ANSI colors, JSON)
|
||||
- [x] Terminal-aware color output
|
||||
- [x] All input formats supported (PostgreSQL, DBML, JSON, GORM, Bun, etc.)
|
||||
- [x] CI/CD integration support (proper exit codes)
|
||||
- [x] Comprehensive documentation and examples
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
#### Reporting Enhancements
|
||||
- [ ] Add verbose mode to show all passing checks in detail
|
||||
- [ ] Add summary-only mode (suppress violation details)
|
||||
- [ ] Group violations by table/schema in report
|
||||
- [ ] Add statistics: most violated rules, tables with most issues
|
||||
- [ ] HTML report format with interactive filtering
|
||||
|
||||
#### Additional Validators
|
||||
- [ ] Optimal column order for space and storage efficiency
|
||||
- [ ] Similar-sounding column names detection (synonyms, typos)
|
||||
- [ ] Plural/singular table name consistency
|
||||
- [ ] Column order validation (PK first, FKs next, data columns, timestamps last)
|
||||
- [ ] Data type consistency across related columns
|
||||
- [ ] Index coverage analysis
|
||||
- [ ] Unused indexes detection
|
||||
- [ ] Missing indexes on commonly filtered columns
|
||||
- [ ] Table size estimates and warnings for large tables
|
||||
- [ ] Function naming conventions (here we have my rules used in Bitech etc. Work from a rules file.)
|
||||
- [ ] View naming conventions
|
||||
- [ ] Enum naming conventions
|
||||
- [ ] Custom type naming conventions
|
||||
- [ ] Table name consistency across related tables
|
||||
|
||||
#### Auto-Fix Capabilities
|
||||
- [ ] Auto-fix mode (`relspec inspect --fix`)
|
||||
- [ ] Update foreign key types to match primary key types
|
||||
- [ ] Rename foreign keys to match primary key names with configurable prefix/suffix
|
||||
- [ ] Reorder columns according to rules
|
||||
- [ ] Add missing indexes on foreign keys
|
||||
- [ ] Generate migration scripts for fixes
|
||||
- [ ] Dry-run mode to preview changes
|
||||
|
||||
#### Advanced Features
|
||||
- [ ] Custom validator plugins (Go plugin system)
|
||||
- [ ] Rule templates for different databases (PostgreSQL, MySQL, etc.)
|
||||
- [ ] Rule inheritance and composition
|
||||
- [ ] Conditional rules (apply only to certain schemas/tables)
|
||||
- [ ] Performance metrics in report (validation time per rule)
|
||||
- [ ] Caching for large databases
|
||||
- [ ] Incremental validation (only changed tables)
|
||||
- [ ] Watch mode for continuous validation
|
||||
|
||||
182
pkg/inspector/inspector.go
Normal file
182
pkg/inspector/inspector.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package inspector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// Inspector performs validation on database models
|
||||
type Inspector struct {
|
||||
config *Config
|
||||
db *models.Database
|
||||
}
|
||||
|
||||
// ValidationResult represents the result of a single validation check
|
||||
type ValidationResult struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
Level string `json:"level"` // "error" or "warning"
|
||||
Message string `json:"message"`
|
||||
Location string `json:"location"` // e.g., "schema.table.column"
|
||||
Context map[string]interface{} `json:"context"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
|
||||
// InspectorReport contains the complete validation report
|
||||
type InspectorReport struct {
|
||||
Summary ReportSummary `json:"summary"`
|
||||
Violations []ValidationResult `json:"violations"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Database string `json:"database"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
|
||||
// ReportSummary contains aggregate statistics
|
||||
type ReportSummary struct {
|
||||
TotalRules int `json:"total_rules"`
|
||||
RulesChecked int `json:"rules_checked"`
|
||||
RulesSkipped int `json:"rules_skipped"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
PassedCount int `json:"passed_count"`
|
||||
}
|
||||
|
||||
// NewInspector creates a new inspector with the given database and configuration
|
||||
func NewInspector(db *models.Database, config *Config) *Inspector {
|
||||
return &Inspector{
|
||||
config: config,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect runs all enabled validation rules and returns a report
|
||||
func (i *Inspector) Inspect() (*InspectorReport, error) {
|
||||
results := []ValidationResult{}
|
||||
|
||||
// Run all enabled validators
|
||||
for ruleName, rule := range i.config.Rules {
|
||||
if !rule.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the validator function for this rule using the function field
|
||||
validator, exists := getValidator(rule.Function)
|
||||
if !exists {
|
||||
// Skip unknown validator functions
|
||||
continue
|
||||
}
|
||||
|
||||
// Run the validator
|
||||
ruleResults := validator(i.db, rule, ruleName)
|
||||
|
||||
// Set the level based on rule configuration
|
||||
level := "warning"
|
||||
if rule.IsEnforced() {
|
||||
level = "error"
|
||||
}
|
||||
|
||||
for idx := range ruleResults {
|
||||
ruleResults[idx].Level = level
|
||||
}
|
||||
|
||||
results = append(results, ruleResults...)
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
summary := i.generateSummary(results)
|
||||
|
||||
report := &InspectorReport{
|
||||
Summary: summary,
|
||||
Violations: results,
|
||||
GeneratedAt: time.Now(),
|
||||
Database: i.db.Name,
|
||||
SourceFormat: i.db.SourceFormat,
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// generateSummary creates summary statistics from validation results
|
||||
func (i *Inspector) generateSummary(results []ValidationResult) ReportSummary {
|
||||
summary := ReportSummary{
|
||||
TotalRules: len(i.config.Rules),
|
||||
}
|
||||
|
||||
// Count enabled rules
|
||||
for _, rule := range i.config.Rules {
|
||||
if rule.IsEnabled() {
|
||||
summary.RulesChecked++
|
||||
} else {
|
||||
summary.RulesSkipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Count violations by level
|
||||
for _, result := range results {
|
||||
if result.Passed {
|
||||
summary.PassedCount++
|
||||
} else {
|
||||
if result.Level == "error" {
|
||||
summary.ErrorCount++
|
||||
} else {
|
||||
summary.WarningCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// HasErrors returns true if the report contains any errors
|
||||
func (r *InspectorReport) HasErrors() bool {
|
||||
return r.Summary.ErrorCount > 0
|
||||
}
|
||||
|
||||
// validatorFunc is a function that validates a rule against a database
|
||||
type validatorFunc func(*models.Database, Rule, string) []ValidationResult
|
||||
|
||||
// getValidator returns the validator function for a given function name
|
||||
func getValidator(functionName string) (validatorFunc, bool) {
|
||||
validators := map[string]validatorFunc{
|
||||
"primary_key_naming": validatePrimaryKeyNaming,
|
||||
"primary_key_datatype": validatePrimaryKeyDatatype,
|
||||
"primary_key_auto_increment": validatePrimaryKeyAutoIncrement,
|
||||
"foreign_key_column_naming": validateForeignKeyColumnNaming,
|
||||
"foreign_key_constraint_naming": validateForeignKeyConstraintNaming,
|
||||
"foreign_key_index": validateForeignKeyIndex,
|
||||
"table_regexpr": validateTableNamingCase,
|
||||
"column_regexpr": validateColumnNamingCase,
|
||||
"table_name_length": validateTableNameLength,
|
||||
"column_name_length": validateColumnNameLength,
|
||||
"reserved_words": validateReservedKeywords,
|
||||
"have_primary_key": validateMissingPrimaryKey,
|
||||
"orphaned_foreign_key": validateOrphanedForeignKey,
|
||||
"circular_dependency": validateCircularDependency,
|
||||
}
|
||||
|
||||
fn, exists := validators[functionName]
|
||||
return fn, exists
|
||||
}
|
||||
|
||||
// createResult is a helper to create a validation result
|
||||
func createResult(ruleName string, passed bool, message string, location string, context map[string]interface{}) ValidationResult {
|
||||
return ValidationResult{
|
||||
RuleName: ruleName,
|
||||
Message: message,
|
||||
Location: location,
|
||||
Context: context,
|
||||
Passed: passed,
|
||||
}
|
||||
}
|
||||
|
||||
// formatLocation creates a location string from schema, table, and optional column
|
||||
func formatLocation(schema, table, column string) string {
|
||||
if column != "" {
|
||||
return fmt.Sprintf("%s.%s.%s", schema, table, column)
|
||||
}
|
||||
if table != "" {
|
||||
return fmt.Sprintf("%s.%s", schema, table)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
229
pkg/inspector/report.go
Normal file
229
pkg/inspector/report.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package inspector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorRed = "\033[31m"
|
||||
colorYellow = "\033[33m"
|
||||
colorGreen = "\033[32m"
|
||||
colorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// ReportFormatter defines the interface for report formatters
|
||||
type ReportFormatter interface {
|
||||
Format(report *InspectorReport) (string, error)
|
||||
}
|
||||
|
||||
// MarkdownFormatter formats reports as markdown
|
||||
type MarkdownFormatter struct {
|
||||
UseColors bool
|
||||
}
|
||||
|
||||
// JSONFormatter formats reports as JSON
|
||||
type JSONFormatter struct{}
|
||||
|
||||
// NewMarkdownFormatter creates a markdown formatter with color support detection
|
||||
func NewMarkdownFormatter(writer io.Writer) *MarkdownFormatter {
|
||||
return &MarkdownFormatter{
|
||||
UseColors: isTerminal(writer),
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONFormatter creates a JSON formatter
|
||||
func NewJSONFormatter() *JSONFormatter {
|
||||
return &JSONFormatter{}
|
||||
}
|
||||
|
||||
// Format generates a markdown report
|
||||
func (f *MarkdownFormatter) Format(report *InspectorReport) (string, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
// Header
|
||||
sb.WriteString(f.formatHeader("RelSpec Inspector Report"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Metadata
|
||||
sb.WriteString(f.formatBold("Database:") + " " + report.Database + "\n")
|
||||
sb.WriteString(f.formatBold("Source Format:") + " " + report.SourceFormat + "\n")
|
||||
sb.WriteString(f.formatBold("Generated:") + " " + report.GeneratedAt.Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Summary
|
||||
sb.WriteString(f.formatHeader("Summary"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(fmt.Sprintf("- Rules Checked: %d\n", report.Summary.RulesChecked))
|
||||
|
||||
// Color-code error and warning counts
|
||||
if report.Summary.ErrorCount > 0 {
|
||||
sb.WriteString(f.colorize(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount), colorRed))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount))
|
||||
}
|
||||
|
||||
if report.Summary.WarningCount > 0 {
|
||||
sb.WriteString(f.colorize(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount), colorYellow))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount))
|
||||
}
|
||||
|
||||
if report.Summary.PassedCount > 0 {
|
||||
sb.WriteString(f.colorize(fmt.Sprintf("- Passed: %d\n", report.Summary.PassedCount), colorGreen))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Group violations by level
|
||||
errors := []ValidationResult{}
|
||||
warnings := []ValidationResult{}
|
||||
|
||||
for _, v := range report.Violations {
|
||||
if !v.Passed {
|
||||
if v.Level == "error" {
|
||||
errors = append(errors, v)
|
||||
} else {
|
||||
warnings = append(warnings, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report violations
|
||||
if len(errors) > 0 || len(warnings) > 0 {
|
||||
sb.WriteString(f.formatHeader("Violations"))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Errors
|
||||
if len(errors) > 0 {
|
||||
sb.WriteString(f.formatSubheader(fmt.Sprintf("Errors (%d)", len(errors)), colorRed))
|
||||
sb.WriteString("\n")
|
||||
for _, violation := range errors {
|
||||
sb.WriteString(f.formatViolation(violation, colorRed))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if len(warnings) > 0 {
|
||||
sb.WriteString(f.formatSubheader(fmt.Sprintf("Warnings (%d)", len(warnings)), colorYellow))
|
||||
sb.WriteString("\n")
|
||||
for _, violation := range warnings {
|
||||
sb.WriteString(f.formatViolation(violation, colorYellow))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(f.colorize("✓ No violations found!\n", colorGreen))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Format generates a JSON report
|
||||
func (f *JSONFormatter) Format(report *InspectorReport) (string, error) {
|
||||
data, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal report to JSON: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Helper methods for MarkdownFormatter
|
||||
|
||||
func (f *MarkdownFormatter) formatHeader(text string) string {
|
||||
return f.formatBold("# " + text)
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) formatSubheader(text string, color string) string {
|
||||
header := "### " + text
|
||||
if f.UseColors {
|
||||
return color + colorBold + header + colorReset
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) formatBold(text string) string {
|
||||
if f.UseColors {
|
||||
return colorBold + text + colorReset
|
||||
}
|
||||
return "**" + text + "**"
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) colorize(text string, color string) string {
|
||||
if f.UseColors {
|
||||
return color + text + colorReset
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) formatViolation(v ValidationResult, color string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Rule name as header
|
||||
if f.UseColors {
|
||||
sb.WriteString(color + "#### " + v.RuleName + colorReset + "\n")
|
||||
} else {
|
||||
sb.WriteString("#### " + v.RuleName + "\n")
|
||||
}
|
||||
|
||||
// Location and message
|
||||
sb.WriteString(f.formatBold("Location:") + " " + v.Location + "\n")
|
||||
sb.WriteString(f.formatBold("Message:") + " " + v.Message + "\n")
|
||||
|
||||
// Context details (optional, only show interesting ones)
|
||||
if len(v.Context) > 0 {
|
||||
contextStr := f.formatContext(v.Context)
|
||||
if contextStr != "" {
|
||||
sb.WriteString(f.formatBold("Details:") + " " + contextStr + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string {
|
||||
// Extract relevant context information
|
||||
var parts []string
|
||||
|
||||
// Skip schema, table, column as they're in location
|
||||
skipKeys := map[string]bool{
|
||||
"schema": true,
|
||||
"table": true,
|
||||
"column": true,
|
||||
}
|
||||
|
||||
for key, value := range context {
|
||||
if skipKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// isTerminal checks if the writer is a terminal (supports ANSI colors)
|
||||
func isTerminal(w io.Writer) bool {
|
||||
file, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the file descriptor is a terminal
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a character device (terminal)
|
||||
// This works on Unix-like systems
|
||||
return (stat.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
169
pkg/inspector/rules.go
Normal file
169
pkg/inspector/rules.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package inspector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the inspector rules configuration
|
||||
type Config struct {
|
||||
Version string `yaml:"version"`
|
||||
Rules map[string]Rule `yaml:"rules"`
|
||||
}
|
||||
|
||||
// Rule represents a single validation rule
|
||||
type Rule struct {
|
||||
Enabled string `yaml:"enabled"` // "enforce", "warn", "off"
|
||||
Function string `yaml:"function"` // validator function name
|
||||
Message string `yaml:"message"`
|
||||
Pattern string `yaml:"pattern,omitempty"`
|
||||
AllowedTypes []string `yaml:"allowed_types,omitempty"`
|
||||
MaxLength int `yaml:"max_length,omitempty"`
|
||||
Case string `yaml:"case,omitempty"`
|
||||
RequireIndex bool `yaml:"require_index,omitempty"`
|
||||
CheckTables bool `yaml:"check_tables,omitempty"`
|
||||
CheckColumns bool `yaml:"check_columns,omitempty"`
|
||||
RequireAutoIncrement bool `yaml:"require_auto_increment,omitempty"`
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from a YAML file
|
||||
// If the file doesn't exist, returns default configuration
|
||||
// If the file exists but is invalid, returns an error
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
// File doesn't exist, use defaults
|
||||
return GetDefaultConfig(), nil
|
||||
}
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config YAML: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// GetDefaultConfig returns the default inspector configuration
|
||||
// All rules are enabled at "warn" level by default
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Version: "1.0",
|
||||
Rules: map[string]Rule{
|
||||
// Primary Key Rules
|
||||
"primary_key_naming": {
|
||||
Enabled: "warn",
|
||||
Function: "primary_key_naming",
|
||||
Pattern: "^id_",
|
||||
Message: "Primary key columns should start with 'id_'",
|
||||
},
|
||||
"primary_key_datatype": {
|
||||
Enabled: "warn",
|
||||
Function: "primary_key_datatype",
|
||||
AllowedTypes: []string{"bigserial", "bigint", "int", "serial", "integer", "int4", "int8"},
|
||||
Message: "Primary keys should use integer types (bigserial, bigint, int, serial)",
|
||||
},
|
||||
"primary_key_auto_increment": {
|
||||
Enabled: "off",
|
||||
Function: "primary_key_auto_increment",
|
||||
RequireAutoIncrement: true,
|
||||
Message: "Primary key without auto-increment detected",
|
||||
},
|
||||
|
||||
// Foreign Key Rules
|
||||
"foreign_key_column_naming": {
|
||||
Enabled: "warn",
|
||||
Function: "foreign_key_column_naming",
|
||||
Pattern: "^rid_",
|
||||
Message: "Foreign key columns should start with 'rid_'",
|
||||
},
|
||||
"foreign_key_constraint_naming": {
|
||||
Enabled: "warn",
|
||||
Function: "foreign_key_constraint_naming",
|
||||
Pattern: "^fk_",
|
||||
Message: "Foreign key constraint names should start with 'fk_'",
|
||||
},
|
||||
"foreign_key_index": {
|
||||
Enabled: "warn",
|
||||
Function: "foreign_key_index",
|
||||
RequireIndex: true,
|
||||
Message: "Foreign key columns should have indexes for optimal performance",
|
||||
},
|
||||
|
||||
// Naming Convention Rules
|
||||
"table_naming_case": {
|
||||
Enabled: "warn",
|
||||
Function: "table_regexpr",
|
||||
Case: "lowercase",
|
||||
Pattern: "^[a-z][a-z0-9_]*$",
|
||||
Message: "Table names should be lowercase with underscores (snake_case)",
|
||||
},
|
||||
"column_naming_case": {
|
||||
Enabled: "warn",
|
||||
Function: "column_regexpr",
|
||||
Case: "lowercase",
|
||||
Pattern: "^[a-z][a-z0-9_]*$",
|
||||
Message: "Column names should be lowercase with underscores (snake_case)",
|
||||
},
|
||||
|
||||
// Length Rules
|
||||
"table_name_length": {
|
||||
Enabled: "warn",
|
||||
Function: "table_name_length",
|
||||
MaxLength: 64,
|
||||
Message: "Table name exceeds recommended maximum length of 64 characters",
|
||||
},
|
||||
"column_name_length": {
|
||||
Enabled: "warn",
|
||||
Function: "column_name_length",
|
||||
MaxLength: 64,
|
||||
Message: "Column name exceeds recommended maximum length of 64 characters",
|
||||
},
|
||||
|
||||
// Reserved Keywords
|
||||
"reserved_keywords": {
|
||||
Enabled: "warn",
|
||||
Function: "reserved_words",
|
||||
CheckTables: true,
|
||||
CheckColumns: true,
|
||||
Message: "Using SQL reserved keywords as identifiers can cause issues",
|
||||
},
|
||||
|
||||
// Schema Integrity Rules
|
||||
"missing_primary_key": {
|
||||
Enabled: "warn",
|
||||
Function: "have_primary_key",
|
||||
Message: "Table is missing a primary key",
|
||||
},
|
||||
"orphaned_foreign_key": {
|
||||
Enabled: "warn",
|
||||
Function: "orphaned_foreign_key",
|
||||
Message: "Foreign key references a non-existent table",
|
||||
},
|
||||
"circular_dependency": {
|
||||
Enabled: "warn",
|
||||
Function: "circular_dependency",
|
||||
Message: "Circular foreign key dependency detected",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns true if the rule is enabled (either "enforce" or "warn")
|
||||
func (r *Rule) IsEnabled() bool {
|
||||
return r.Enabled == "enforce" || r.Enabled == "warn"
|
||||
}
|
||||
|
||||
// IsEnforced returns true if the rule is set to "enforce" level
|
||||
func (r *Rule) IsEnforced() bool {
|
||||
return r.Enabled == "enforce"
|
||||
}
|
||||
603
pkg/inspector/validators.go
Normal file
603
pkg/inspector/validators.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package inspector
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
// validatePrimaryKeyNaming checks that primary key column names match a pattern
|
||||
func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
pattern, err := regexp.Compile(rule.Pattern)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
passed := pattern.MatchString(col.Name)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"expected_pattern": rule.Pattern,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validatePrimaryKeyDatatype checks that primary keys use approved data types
|
||||
func validatePrimaryKeyDatatype(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
|
||||
// Normalize type (remove size/precision)
|
||||
normalizedType := normalizeDataType(col.Type)
|
||||
passed := contains(rule.AllowedTypes, normalizedType)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"current_type": col.Type,
|
||||
"allowed_types": rule.AllowedTypes,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validatePrimaryKeyAutoIncrement checks primary key auto-increment settings
|
||||
func validatePrimaryKeyAutoIncrement(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
|
||||
// Check if auto-increment matches requirement
|
||||
passed := col.AutoIncrement == rule.RequireAutoIncrement
|
||||
|
||||
if !passed {
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
false,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"has_auto_increment": col.AutoIncrement,
|
||||
"require_auto_increment": rule.RequireAutoIncrement,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateForeignKeyColumnNaming checks that foreign key column names match a pattern
|
||||
func validateForeignKeyColumnNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
pattern, err := regexp.Compile(rule.Pattern)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
// Check foreign key constraints
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, colName := range constraint.Columns {
|
||||
location := formatLocation(schema.Name, table.Name, colName)
|
||||
passed := pattern.MatchString(colName)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": colName,
|
||||
"constraint": constraint.Name,
|
||||
"expected_pattern": rule.Pattern,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateForeignKeyConstraintNaming checks that foreign key constraint names match a pattern
|
||||
func validateForeignKeyConstraintNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
pattern, err := regexp.Compile(rule.Pattern)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
passed := pattern.MatchString(constraint.Name)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"constraint": constraint.Name,
|
||||
"expected_pattern": rule.Pattern,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateForeignKeyIndex checks that foreign key columns have indexes
|
||||
func validateForeignKeyIndex(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
if !rule.RequireIndex {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
// Get all foreign key columns
|
||||
fkColumns := make(map[string]bool)
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, col := range constraint.Columns {
|
||||
fkColumns[col] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if each FK column has an index
|
||||
for fkCol := range fkColumns {
|
||||
hasIndex := false
|
||||
|
||||
// Check table indexes
|
||||
for _, index := range table.Indexes {
|
||||
// Index is good if FK column is the first column
|
||||
if len(index.Columns) > 0 && index.Columns[0] == fkCol {
|
||||
hasIndex = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
location := formatLocation(schema.Name, table.Name, fkCol)
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
hasIndex,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": fkCol,
|
||||
"has_index": hasIndex,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateTableNamingCase checks table name casing
|
||||
func validateTableNamingCase(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
pattern, err := regexp.Compile(rule.Pattern)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
passed := pattern.MatchString(table.Name)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"expected_case": rule.Case,
|
||||
"expected_pattern": rule.Pattern,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateColumnNamingCase checks column name casing
|
||||
func validateColumnNamingCase(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
pattern, err := regexp.Compile(rule.Pattern)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
passed := pattern.MatchString(col.Name)
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"expected_case": rule.Case,
|
||||
"expected_pattern": rule.Pattern,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateTableNameLength checks table name length
|
||||
func validateTableNameLength(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
passed := len(table.Name) <= rule.MaxLength
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"length": len(table.Name),
|
||||
"max_length": rule.MaxLength,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateColumnNameLength checks column name length
|
||||
func validateColumnNameLength(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
passed := len(col.Name) <= rule.MaxLength
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"length": len(col.Name),
|
||||
"max_length": rule.MaxLength,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateReservedKeywords checks for reserved SQL keywords
|
||||
func validateReservedKeywords(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
// Build keyword map from PostgreSQL keywords
|
||||
keywordSlice := pgsql.GetPostgresKeywords()
|
||||
keywords := make(map[string]bool)
|
||||
for _, kw := range keywordSlice {
|
||||
keywords[strings.ToUpper(kw)] = true
|
||||
}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
// Check table name
|
||||
if rule.CheckTables {
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
passed := !keywords[strings.ToUpper(table.Name)]
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"object_type": "table",
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// Check column names
|
||||
if rule.CheckColumns {
|
||||
for _, col := range table.Columns {
|
||||
location := formatLocation(schema.Name, table.Name, col.Name)
|
||||
passed := !keywords[strings.ToUpper(col.Name)]
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"column": col.Name,
|
||||
"object_type": "column",
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateMissingPrimaryKey checks for tables without primary keys
|
||||
func validateMissingPrimaryKey(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
hasPrimaryKey := false
|
||||
|
||||
// Check columns for primary key
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
hasPrimaryKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also check constraints
|
||||
if !hasPrimaryKey {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
hasPrimaryKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
hasPrimaryKey,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateOrphanedForeignKey checks for foreign keys referencing non-existent tables
|
||||
func validateOrphanedForeignKey(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
// Build a map of existing tables for quick lookup
|
||||
tableExists := make(map[string]bool)
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
key := schema.Name + "." + table.Name
|
||||
tableExists[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check all foreign key constraints
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
// Build referenced table key
|
||||
refSchema := constraint.ReferencedSchema
|
||||
if refSchema == "" {
|
||||
refSchema = schema.Name
|
||||
}
|
||||
refKey := refSchema + "." + constraint.ReferencedTable
|
||||
|
||||
location := formatLocation(schema.Name, table.Name, "")
|
||||
passed := tableExists[refKey]
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
passed,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"table": table.Name,
|
||||
"constraint": constraint.Name,
|
||||
"referenced_schema": refSchema,
|
||||
"referenced_table": constraint.ReferencedTable,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// validateCircularDependency checks for circular foreign key dependencies
|
||||
func validateCircularDependency(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
// Build dependency graph
|
||||
dependencies := make(map[string][]string)
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
refSchema := constraint.ReferencedSchema
|
||||
if refSchema == "" {
|
||||
refSchema = schema.Name
|
||||
}
|
||||
refKey := refSchema + "." + constraint.ReferencedTable
|
||||
|
||||
dependencies[tableKey] = append(dependencies[tableKey], refKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles using DFS
|
||||
for tableKey := range dependencies {
|
||||
visited := make(map[string]bool)
|
||||
recStack := make(map[string]bool)
|
||||
|
||||
if hasCycle(tableKey, dependencies, visited, recStack) {
|
||||
parts := strings.Split(tableKey, ".")
|
||||
location := formatLocation(parts[0], parts[1], "")
|
||||
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
false,
|
||||
rule.Message,
|
||||
location,
|
||||
map[string]interface{}{
|
||||
"schema": parts[0],
|
||||
"table": parts[1],
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// hasCycle performs DFS to detect cycles in dependency graph
|
||||
func hasCycle(node string, graph map[string][]string, visited, recStack map[string]bool) bool {
|
||||
visited[node] = true
|
||||
recStack[node] = true
|
||||
|
||||
for _, neighbor := range graph[node] {
|
||||
if !visited[neighbor] {
|
||||
if hasCycle(neighbor, graph, visited, recStack) {
|
||||
return true
|
||||
}
|
||||
} else if recStack[neighbor] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
recStack[node] = false
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeDataType removes size/precision from data type
|
||||
func normalizeDataType(dataType string) string {
|
||||
// Remove everything in parentheses
|
||||
idx := strings.Index(dataType, "(")
|
||||
if idx > 0 {
|
||||
dataType = dataType[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(dataType))
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a value
|
||||
func contains(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if strings.EqualFold(item, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
@@ -21,10 +26,13 @@ type Database struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
Schemas []*Schema `json:"schemas" yaml:"schemas" xml:"schemas"`
|
||||
Domains []*Domain `json:"domains,omitempty" yaml:"domains,omitempty" xml:"domains,omitempty"`
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
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.
|
||||
@@ -32,6 +40,39 @@ 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 {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
Tables []*DomainTable `json:"tables" yaml:"tables" xml:"tables"`
|
||||
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.
|
||||
func (d *Domain) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// DomainTable represents a reference to a specific table within a domain.
|
||||
// It identifies the table by name and schema, allowing a single domain to include
|
||||
// tables from multiple schemas.
|
||||
type DomainTable struct {
|
||||
TableName string `json:"table_name" yaml:"table_name" xml:"table_name"`
|
||||
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
|
||||
// such as tables, views, sequences, and relationships within a database.
|
||||
type Schema struct {
|
||||
@@ -49,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.
|
||||
@@ -71,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.
|
||||
@@ -111,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.
|
||||
@@ -134,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.
|
||||
@@ -158,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.
|
||||
@@ -180,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.
|
||||
@@ -214,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.
|
||||
@@ -238,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.
|
||||
@@ -253,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.
|
||||
@@ -260,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
|
||||
@@ -281,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.
|
||||
@@ -295,6 +374,8 @@ func InitDatabase(name string) *Database {
|
||||
return &Database{
|
||||
Name: name,
|
||||
Schemas: make([]*Schema, 0),
|
||||
Domains: make([]*Domain, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +413,7 @@ func InitColumn(name, table, schema string) *Column {
|
||||
Name: name,
|
||||
Table: table,
|
||||
Schema: schema,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +425,7 @@ func InitIndex(name, table, schema string) *Index {
|
||||
Schema: schema,
|
||||
Columns: make([]string, 0),
|
||||
Include: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +448,7 @@ func InitRelationship(name string, relType RelationType) *Relationship {
|
||||
Name: name,
|
||||
Type: relType,
|
||||
Properties: make(map[string]string),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +459,7 @@ func InitConstraint(name string, constraintType ConstraintType) *Constraint {
|
||||
Type: constraintType,
|
||||
Columns: make([]string, 0),
|
||||
ReferencedColumns: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +468,7 @@ func InitScript(name string) *Script {
|
||||
return &Script{
|
||||
Name: name,
|
||||
RunAfter: make([]string, 0),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,5 +490,25 @@ func InitSequence(name, schema string) *Sequence {
|
||||
Schema: schema,
|
||||
IncrementBy: 1,
|
||||
StartValue: 1,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// InitDomain initializes a new Domain with empty slices and maps
|
||||
func InitDomain(name string) *Domain {
|
||||
return &Domain{
|
||||
Name: name,
|
||||
Tables: make([]*DomainTable, 0),
|
||||
Metadata: make(map[string]any),
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// InitDomainTable initializes a new DomainTable reference
|
||||
func InitDomainTable(tableName, schemaName string) *DomainTable {
|
||||
return &DomainTable{
|
||||
TableName: tableName,
|
||||
SchemaName: schemaName,
|
||||
GUID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
282
pkg/models/sorting.go
Normal file
282
pkg/models/sorting.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SortOrder represents the sort direction
|
||||
type SortOrder bool
|
||||
|
||||
const (
|
||||
// Ascending sort order
|
||||
Ascending SortOrder = false
|
||||
// Descending sort order
|
||||
Descending SortOrder = true
|
||||
)
|
||||
|
||||
// Schema Sorting
|
||||
|
||||
// SortSchemasByName sorts schemas by name
|
||||
func SortSchemasByName(schemas []*Schema, desc bool) error {
|
||||
sort.SliceStable(schemas, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(schemas[i].Name), strings.ToLower(schemas[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortSchemasBySequence sorts schemas by sequence number
|
||||
func SortSchemasBySequence(schemas []*Schema, desc bool) error {
|
||||
sort.SliceStable(schemas, func(i, j int) bool {
|
||||
if desc {
|
||||
return schemas[i].Sequence > schemas[j].Sequence
|
||||
}
|
||||
return schemas[i].Sequence < schemas[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table Sorting
|
||||
|
||||
// SortTablesByName sorts tables by name
|
||||
func SortTablesByName(tables []*Table, desc bool) error {
|
||||
sort.SliceStable(tables, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(tables[i].Name), strings.ToLower(tables[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortTablesBySequence sorts tables by sequence number
|
||||
func SortTablesBySequence(tables []*Table, desc bool) error {
|
||||
sort.SliceStable(tables, func(i, j int) bool {
|
||||
if desc {
|
||||
return tables[i].Sequence > tables[j].Sequence
|
||||
}
|
||||
return tables[i].Sequence < tables[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Column Sorting
|
||||
|
||||
// SortColumnsMapByName converts column map to sorted slice by name
|
||||
func SortColumnsMapByName(columns map[string]*Column, desc bool) []*Column {
|
||||
result := make([]*Column, 0, len(columns))
|
||||
for _, col := range columns {
|
||||
result = append(result, col)
|
||||
}
|
||||
_ = SortColumnsByName(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortColumnsMapBySequence converts column map to sorted slice by sequence
|
||||
func SortColumnsMapBySequence(columns map[string]*Column, desc bool) []*Column {
|
||||
result := make([]*Column, 0, len(columns))
|
||||
for _, col := range columns {
|
||||
result = append(result, col)
|
||||
}
|
||||
_ = SortColumnsBySequence(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortColumnsByName sorts columns by name
|
||||
func SortColumnsByName(columns []*Column, desc bool) error {
|
||||
sort.SliceStable(columns, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(columns[i].Name), strings.ToLower(columns[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortColumnsBySequence sorts columns by sequence number
|
||||
func SortColumnsBySequence(columns []*Column, desc bool) error {
|
||||
sort.SliceStable(columns, func(i, j int) bool {
|
||||
if desc {
|
||||
return columns[i].Sequence > columns[j].Sequence
|
||||
}
|
||||
return columns[i].Sequence < columns[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// View Sorting
|
||||
|
||||
// SortViewsByName sorts views by name
|
||||
func SortViewsByName(views []*View, desc bool) error {
|
||||
sort.SliceStable(views, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(views[i].Name), strings.ToLower(views[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortViewsBySequence sorts views by sequence number
|
||||
func SortViewsBySequence(views []*View, desc bool) error {
|
||||
sort.SliceStable(views, func(i, j int) bool {
|
||||
if desc {
|
||||
return views[i].Sequence > views[j].Sequence
|
||||
}
|
||||
return views[i].Sequence < views[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sequence Sorting
|
||||
|
||||
// SortSequencesByName sorts sequences by name
|
||||
func SortSequencesByName(sequences []*Sequence, desc bool) error {
|
||||
sort.SliceStable(sequences, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(sequences[i].Name), strings.ToLower(sequences[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortSequencesBySequence sorts sequences by sequence number
|
||||
func SortSequencesBySequence(sequences []*Sequence, desc bool) error {
|
||||
sort.SliceStable(sequences, func(i, j int) bool {
|
||||
if desc {
|
||||
return sequences[i].Sequence > sequences[j].Sequence
|
||||
}
|
||||
return sequences[i].Sequence < sequences[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index Sorting
|
||||
|
||||
// SortIndexesMapByName converts index map to sorted slice by name
|
||||
func SortIndexesMapByName(indexes map[string]*Index, desc bool) []*Index {
|
||||
result := make([]*Index, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
result = append(result, idx)
|
||||
}
|
||||
_ = SortIndexesByName(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortIndexesMapBySequence converts index map to sorted slice by sequence
|
||||
func SortIndexesMapBySequence(indexes map[string]*Index, desc bool) []*Index {
|
||||
result := make([]*Index, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
result = append(result, idx)
|
||||
}
|
||||
_ = SortIndexesBySequence(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortIndexesByName sorts indexes by name
|
||||
func SortIndexesByName(indexes []*Index, desc bool) error {
|
||||
sort.SliceStable(indexes, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(indexes[i].Name), strings.ToLower(indexes[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortIndexesBySequence sorts indexes by sequence number
|
||||
func SortIndexesBySequence(indexes []*Index, desc bool) error {
|
||||
sort.SliceStable(indexes, func(i, j int) bool {
|
||||
if desc {
|
||||
return indexes[i].Sequence > indexes[j].Sequence
|
||||
}
|
||||
return indexes[i].Sequence < indexes[j].Sequence
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Constraint Sorting
|
||||
|
||||
// SortConstraintsMapByName converts constraint map to sorted slice by name
|
||||
func SortConstraintsMapByName(constraints map[string]*Constraint, desc bool) []*Constraint {
|
||||
result := make([]*Constraint, 0, len(constraints))
|
||||
for _, c := range constraints {
|
||||
result = append(result, c)
|
||||
}
|
||||
_ = SortConstraintsByName(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortConstraintsByName sorts constraints by name
|
||||
func SortConstraintsByName(constraints []*Constraint, desc bool) error {
|
||||
sort.SliceStable(constraints, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(constraints[i].Name), strings.ToLower(constraints[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relationship Sorting
|
||||
|
||||
// SortRelationshipsMapByName converts relationship map to sorted slice by name
|
||||
func SortRelationshipsMapByName(relationships map[string]*Relationship, desc bool) []*Relationship {
|
||||
result := make([]*Relationship, 0, len(relationships))
|
||||
for _, r := range relationships {
|
||||
result = append(result, r)
|
||||
}
|
||||
_ = SortRelationshipsByName(result, desc)
|
||||
return result
|
||||
}
|
||||
|
||||
// SortRelationshipsByName sorts relationships by name
|
||||
func SortRelationshipsByName(relationships []*Relationship, desc bool) error {
|
||||
sort.SliceStable(relationships, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(relationships[i].Name), strings.ToLower(relationships[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Script Sorting
|
||||
|
||||
// SortScriptsByName sorts scripts by name
|
||||
func SortScriptsByName(scripts []*Script, desc bool) error {
|
||||
sort.SliceStable(scripts, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(scripts[i].Name), strings.ToLower(scripts[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enum Sorting
|
||||
|
||||
// SortEnumsByName sorts enums by name
|
||||
func SortEnumsByName(enums []*Enum, desc bool) error {
|
||||
sort.SliceStable(enums, func(i, j int) bool {
|
||||
cmp := strings.Compare(strings.ToLower(enums[i].Name), strings.ToLower(enums[j].Name))
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -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,301 @@ 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -332,27 +632,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)}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -140,6 +140,32 @@ func (r *Reader) convertToDatabase(drawSchema *drawdb.DrawDBSchema) (*models.Dat
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
}
|
||||
|
||||
// Convert DrawDB subject areas to domains
|
||||
for _, area := range drawSchema.SubjectAreas {
|
||||
domain := models.InitDomain(area.Name)
|
||||
|
||||
// Find all tables that visually belong to this area
|
||||
// A table belongs to an area if its position is within the area bounds
|
||||
for _, drawTable := range drawSchema.Tables {
|
||||
if drawTable.X >= area.X && drawTable.X <= (area.X+area.Width) &&
|
||||
drawTable.Y >= area.Y && drawTable.Y <= (area.Y+area.Height) {
|
||||
|
||||
schemaName := drawTable.Schema
|
||||
if schemaName == "" {
|
||||
schemaName = "public"
|
||||
}
|
||||
|
||||
domainTable := models.InitDomainTable(drawTable.Name, schemaName)
|
||||
domain.Tables = append(domain.Tables, domainTable)
|
||||
}
|
||||
}
|
||||
|
||||
// Only add domain if it has tables
|
||||
if len(domain.Tables) > 0 {
|
||||
db.Domains = append(db.Domains, domain)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
160
pkg/readers/sqldir/README.md
Normal file
160
pkg/readers/sqldir/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# SQL Directory Reader
|
||||
|
||||
The SQL Directory Reader (`sqldir`) reads SQL scripts from a directory structure and populates the `Scripts` field of a `Schema`. It supports recursive directory scanning and extracts priority, sequence, and name information from filenames.
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
Scripts must follow this naming pattern (supports both underscores and hyphens as separators):
|
||||
|
||||
```
|
||||
{priority}_{sequence}_{name}.{sql|pgsql}
|
||||
{priority}-{sequence}-{name}.{sql|pgsql}
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- **priority**: Integer (0-9999) - Defines execution order (lower executes first)
|
||||
- **sequence**: Integer (0-9999) - Defines order within the same priority level
|
||||
- **separator**: Underscore `_` or hyphen `-` (can be mixed)
|
||||
- **name**: Descriptive name (alphanumeric, underscores, hyphens allowed)
|
||||
- **extension**: `.sql` or `.pgsql`
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 1_001_create_schema.sql # Priority 1, Sequence 1 (underscore format)
|
||||
├── 1-002-create-users-table.sql # Priority 1, Sequence 2 (hyphen format)
|
||||
├── 1_003_create_posts_table.pgsql # Priority 1, Sequence 3 (underscore format)
|
||||
├── 2-001-add-indexes.sql # Priority 2, Sequence 1 (hyphen format)
|
||||
├── 2_002_add_constraints.sql # Priority 2, Sequence 2 (underscore format)
|
||||
├── 10-10-create-newid.pgsql # Priority 10, Sequence 10 (hyphen format)
|
||||
└── subdirectory/
|
||||
└── 3_001_seed_data.sql # Priority 3, Sequence 1 (subdirs supported)
|
||||
```
|
||||
|
||||
**Execution Order**: 1→2→3→4→5→6→7 (sorted by Priority ascending, then Sequence ascending)
|
||||
|
||||
**Both formats can be mixed** in the same directory - the reader handles both seamlessly.
|
||||
|
||||
### Invalid Filenames (Ignored)
|
||||
|
||||
- `migration.sql` - Missing priority/sequence
|
||||
- `1_create_users.sql` - Missing sequence
|
||||
- `create_users.sql` - Missing priority/sequence
|
||||
- `1_001_test.txt` - Wrong extension
|
||||
- `readme.md` - Not a SQL file
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
)
|
||||
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "/path/to/migrations",
|
||||
Metadata: map[string]any{
|
||||
"schema_name": "public", // Optional, defaults to "public"
|
||||
"database_name": "myapp", // Optional, defaults to "database"
|
||||
},
|
||||
})
|
||||
|
||||
// Read all scripts
|
||||
database, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Access scripts
|
||||
for _, schema := range database.Schemas {
|
||||
for _, script := range schema.Scripts {
|
||||
fmt.Printf("Script: %s (P:%d S:%d)\n",
|
||||
script.Name, script.Priority, script.Sequence)
|
||||
fmt.Printf("SQL: %s\n", script.SQL)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Read Schema Only
|
||||
|
||||
```go
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d scripts\n", len(schema.Scripts))
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Recursive Directory Scanning**: Automatically scans all subdirectories
|
||||
- **Multiple Extensions**: Supports both `.sql` and `.pgsql` files
|
||||
- **Flexible Naming**: Extract metadata from filename patterns
|
||||
- **Error Handling**: Validates directory existence and file accessibility
|
||||
- **Schema Integration**: Scripts are added to the standard RelSpec `Schema` model
|
||||
|
||||
## Script Model
|
||||
|
||||
Each script is stored as a `models.Script`:
|
||||
|
||||
```go
|
||||
type Script struct {
|
||||
Name string // Extracted from filename (e.g., "create_users")
|
||||
Description string // Auto-generated description with file path
|
||||
SQL string // Complete SQL content from file
|
||||
Priority int // Execution priority from filename
|
||||
Sequence uint // Execution sequence from filename
|
||||
// ... other fields available but not populated by this reader
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with SQL Executor
|
||||
|
||||
The SQL Directory Reader is designed to work seamlessly with the SQL Executor Writer:
|
||||
|
||||
```go
|
||||
// Read scripts
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "./migrations",
|
||||
})
|
||||
db, _ := reader.ReadDatabase()
|
||||
|
||||
// Execute scripts
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/mydb",
|
||||
},
|
||||
})
|
||||
writer.WriteDatabase(db) // Executes in Priority→Sequence order
|
||||
```
|
||||
|
||||
See `pkg/writers/sqlexec/README.md` for more details on script execution.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The reader will return errors for:
|
||||
- Non-existent directory paths
|
||||
- Inaccessible directories or files
|
||||
- Invalid file permissions
|
||||
- File read failures
|
||||
|
||||
Files that don't match the naming pattern are silently ignored (not treated as errors).
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
go test ./pkg/readers/sqldir/
|
||||
```
|
||||
|
||||
Tests include:
|
||||
- Valid file parsing
|
||||
- Recursive directory scanning
|
||||
- Invalid filename handling
|
||||
- Empty directory handling
|
||||
- Error conditions
|
||||
127
pkg/readers/sqldir/example_test.go
Normal file
127
pkg/readers/sqldir/example_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package sqldir_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
)
|
||||
|
||||
// Example demonstrates how to read SQL scripts from a directory and execute them
|
||||
func Example() {
|
||||
// Step 1: Read SQL scripts from a directory
|
||||
// Directory structure example:
|
||||
// migrations/
|
||||
// 1_001_create_schema.sql
|
||||
// 1_002_create_users_table.sql
|
||||
// 1_003_create_posts_table.pgsql
|
||||
// 2_001_add_indexes.sql
|
||||
// 2_002_seed_data.sql
|
||||
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "/path/to/migrations",
|
||||
Metadata: map[string]any{
|
||||
"schema_name": "public",
|
||||
"database_name": "myapp",
|
||||
},
|
||||
})
|
||||
|
||||
// Read the database schema with scripts
|
||||
database, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read scripts: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Read %d schemas\n", len(database.Schemas))
|
||||
fmt.Printf("Found %d scripts in schema '%s'\n",
|
||||
len(database.Schemas[0].Scripts),
|
||||
database.Schemas[0].Name)
|
||||
|
||||
// Step 2: Execute the scripts against a PostgreSQL database
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://user:password@localhost:5432/myapp?sslmode=disable",
|
||||
},
|
||||
})
|
||||
|
||||
// Execute all scripts in Priority then Sequence order
|
||||
if err := writer.WriteDatabase(database); err != nil {
|
||||
log.Fatalf("Failed to execute scripts: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("All scripts executed successfully!")
|
||||
}
|
||||
|
||||
// Example_withSingleSchema shows how to read and execute scripts for a single schema
|
||||
func Example_withSingleSchema() {
|
||||
// Read scripts
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "/path/to/migrations",
|
||||
})
|
||||
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read schema: %v", err)
|
||||
}
|
||||
|
||||
// Execute scripts
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/testdb",
|
||||
},
|
||||
})
|
||||
|
||||
if err := writer.WriteSchema(schema); err != nil {
|
||||
log.Fatalf("Failed to execute scripts: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Schema scripts executed successfully!")
|
||||
}
|
||||
|
||||
// Example_fileNamingConvention shows the expected file naming pattern
|
||||
func Example_fileNamingConvention() {
|
||||
// File naming pattern: {priority}_{sequence}_{name}.sql or .pgsql
|
||||
// OR: {priority}-{sequence}-{name}.sql or .pgsql
|
||||
//
|
||||
// Both underscore (_) and hyphen (-) separators are supported and can be mixed.
|
||||
//
|
||||
// Components:
|
||||
// - priority: Integer (0-9999) - Scripts with lower priority execute first
|
||||
// - sequence: Integer (0-9999) - Within same priority, lower sequence executes first
|
||||
// - separator: Underscore (_) or hyphen (-)
|
||||
// - name: Descriptive name (alphanumeric, underscores, hyphens)
|
||||
// - extension: .sql or .pgsql
|
||||
//
|
||||
// Examples (underscore format):
|
||||
// ✓ 1_001_create_users.sql (Priority=1, Sequence=1)
|
||||
// ✓ 1_002_create_posts.sql (Priority=1, Sequence=2)
|
||||
// ✓ 2_001_add_indexes.pgsql (Priority=2, Sequence=1)
|
||||
// ✓ 10_100_migration.sql (Priority=10, Sequence=100)
|
||||
//
|
||||
// Examples (hyphen format):
|
||||
// ✓ 1-001-create-users.sql (Priority=1, Sequence=1)
|
||||
// ✓ 1-002-create-posts.sql (Priority=1, Sequence=2)
|
||||
// ✓ 2-001-add-indexes.pgsql (Priority=2, Sequence=1)
|
||||
// ✓ 10-10-create-newid.pgsql (Priority=10, Sequence=10)
|
||||
//
|
||||
// Mixed format (both in same directory):
|
||||
// ✓ 1_001_create_users.sql (underscore format)
|
||||
// ✓ 1-002-create-posts.sql (hyphen format)
|
||||
// ✓ 2_001_add_indexes.sql (underscore format)
|
||||
//
|
||||
// Execution order for mixed examples:
|
||||
// 1. 1_001_create_users.sql (Priority 1, Sequence 1)
|
||||
// 2. 1-002-create-posts.sql (Priority 1, Sequence 2)
|
||||
// 3. 2_001_add_indexes.sql (Priority 2, Sequence 1)
|
||||
//
|
||||
// Invalid filenames (will be ignored):
|
||||
// ✗ migration.sql (missing priority/sequence)
|
||||
// ✗ 1_create_users.sql (missing sequence)
|
||||
// ✗ create_users.sql (missing priority/sequence)
|
||||
// ✗ 1_001_create_users.txt (wrong extension)
|
||||
|
||||
fmt.Println("See comments for file naming conventions")
|
||||
}
|
||||
169
pkg/readers/sqldir/reader.go
Normal file
169
pkg/readers/sqldir/reader.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package sqldir
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for SQL script directories
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
}
|
||||
|
||||
// NewReader creates a new SQL directory reader
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads all SQL scripts from a directory into a Database
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("directory path is required")
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
info, err := os.Stat(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to access directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("path is not a directory: %s", r.options.FilePath)
|
||||
}
|
||||
|
||||
// Read scripts from directory
|
||||
scripts, err := r.readScripts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read scripts: %w", err)
|
||||
}
|
||||
|
||||
// Get schema name from metadata or use default
|
||||
schemaName := "public"
|
||||
if name, ok := r.options.Metadata["schema_name"].(string); ok && name != "" {
|
||||
schemaName = name
|
||||
}
|
||||
|
||||
// Create schema with scripts
|
||||
schema := &models.Schema{
|
||||
Name: schemaName,
|
||||
Scripts: scripts,
|
||||
}
|
||||
|
||||
// Get database name from metadata or use default
|
||||
dbName := "database"
|
||||
if name, ok := r.options.Metadata["database_name"].(string); ok && name != "" {
|
||||
dbName = name
|
||||
}
|
||||
|
||||
// Create database with schema
|
||||
database := &models.Database{
|
||||
Name: dbName,
|
||||
Schemas: []*models.Schema{schema},
|
||||
}
|
||||
|
||||
// Set back-reference
|
||||
schema.RefDatabase = database
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// ReadSchema reads all SQL scripts from a directory into a Schema
|
||||
func (r *Reader) ReadSchema() (*models.Schema, error) {
|
||||
db, err := r.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
return nil, fmt.Errorf("no schema found")
|
||||
}
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable is not applicable for SQL script directories
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
return nil, fmt.Errorf("ReadTable is not supported for SQL script directories")
|
||||
}
|
||||
|
||||
// readScripts recursively scans the directory for SQL files and parses them into Script models
|
||||
func (r *Reader) readScripts() ([]*models.Script, error) {
|
||||
var scripts []*models.Script
|
||||
|
||||
// Regular expression to parse filename: {priority}{sep}{sequence}{sep}{name}.sql or .pgsql
|
||||
// Separator can be underscore (_) or hyphen (-)
|
||||
// Example: 1_001_create_users.sql -> priority=1, sequence=001, name=create_users
|
||||
// Example: 2_005_add_indexes.pgsql -> priority=2, sequence=005, name=add_indexes
|
||||
// Example: 10-10-create-newid.pgsql -> priority=10, sequence=10, name=create-newid
|
||||
pattern := regexp.MustCompile(`^(\d+)[_-](\d+)[_-](.+)\.(sql|pgsql)$`)
|
||||
|
||||
err := filepath.WalkDir(r.options.FilePath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get filename
|
||||
filename := d.Name()
|
||||
|
||||
// Match against pattern
|
||||
matches := pattern.FindStringSubmatch(filename)
|
||||
if matches == nil {
|
||||
// Skip files that don't match the pattern
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse priority
|
||||
priority, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid priority in filename %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Parse sequence
|
||||
sequence, err := strconv.ParseUint(matches[2], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid sequence in filename %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Extract name
|
||||
name := matches[3]
|
||||
|
||||
// Read SQL content
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Get relative path from base directory
|
||||
relPath, err := filepath.Rel(r.options.FilePath, path)
|
||||
if err != nil {
|
||||
relPath = path
|
||||
}
|
||||
|
||||
// Create Script model
|
||||
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)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scripts, nil
|
||||
}
|
||||
375
pkg/readers/sqldir/reader_test.go
Normal file
375
pkg/readers/sqldir/reader_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package sqldir
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_ReadDatabase(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test SQL files with both underscore and hyphen separators
|
||||
testFiles := map[string]string{
|
||||
"1_001_create_users.sql": "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);",
|
||||
"1_002_create_posts.sql": "CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INT);",
|
||||
"2_001_add_indexes.sql": "CREATE INDEX idx_posts_user_id ON posts(user_id);",
|
||||
"1_003_seed_data.pgsql": "INSERT INTO users (name) VALUES ('Alice'), ('Bob');",
|
||||
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL PRIMARY KEY);",
|
||||
"2-005-add-column.sql": "ALTER TABLE users ADD COLUMN email TEXT;",
|
||||
}
|
||||
|
||||
for filename, content := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create subdirectory with additional script
|
||||
subDir := filepath.Join(tempDir, "migrations")
|
||||
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
subFile := filepath.Join(subDir, "3_001_add_column.sql")
|
||||
if err := os.WriteFile(subFile, []byte("ALTER TABLE users ADD COLUMN email TEXT;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory file: %v", err)
|
||||
}
|
||||
|
||||
// Create reader
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
Metadata: map[string]any{
|
||||
"schema_name": "test_schema",
|
||||
"database_name": "test_db",
|
||||
},
|
||||
})
|
||||
|
||||
// Read database
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify database
|
||||
if db.Name != "test_db" {
|
||||
t.Errorf("Expected database name 'test_db', got '%s'", db.Name)
|
||||
}
|
||||
|
||||
if len(db.Schemas) != 1 {
|
||||
t.Fatalf("Expected 1 schema, got %d", len(db.Schemas))
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if schema.Name != "test_schema" {
|
||||
t.Errorf("Expected schema name 'test_schema', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
// Verify scripts (should be 7 total: 4 underscore + 2 hyphen + 1 subdirectory)
|
||||
if len(schema.Scripts) != 7 {
|
||||
t.Fatalf("Expected 7 scripts, got %d", len(schema.Scripts))
|
||||
}
|
||||
|
||||
// Verify script details
|
||||
expectedScripts := []struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
}{
|
||||
{"create_users", 1, 1},
|
||||
{"create_posts", 1, 2},
|
||||
{"seed_data", 1, 3},
|
||||
{"add_indexes", 2, 1},
|
||||
{"add-column", 2, 5},
|
||||
{"add_column", 3, 1},
|
||||
{"create-newid", 10, 10},
|
||||
}
|
||||
|
||||
scriptMap := make(map[string]*struct {
|
||||
priority int
|
||||
sequence uint
|
||||
sql string
|
||||
})
|
||||
for _, script := range schema.Scripts {
|
||||
scriptMap[script.Name] = &struct {
|
||||
priority int
|
||||
sequence uint
|
||||
sql string
|
||||
}{
|
||||
priority: script.Priority,
|
||||
sequence: script.Sequence,
|
||||
sql: script.SQL,
|
||||
}
|
||||
}
|
||||
|
||||
for _, expected := range expectedScripts {
|
||||
script, exists := scriptMap[expected.name]
|
||||
if !exists {
|
||||
t.Errorf("Expected script '%s' not found", expected.name)
|
||||
continue
|
||||
}
|
||||
if script.priority != expected.priority {
|
||||
t.Errorf("Script '%s': expected priority %d, got %d",
|
||||
expected.name, expected.priority, script.priority)
|
||||
}
|
||||
if script.sequence != expected.sequence {
|
||||
t.Errorf("Script '%s': expected sequence %d, got %d",
|
||||
expected.name, expected.sequence, script.sequence)
|
||||
}
|
||||
if script.sql == "" {
|
||||
t.Errorf("Script '%s': SQL content is empty", expected.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test SQL file
|
||||
testFile := filepath.Join(tempDir, "1_001_test.sql")
|
||||
if err := os.WriteFile(testFile, []byte("SELECT 1;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Create reader
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
})
|
||||
|
||||
// Read schema
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSchema failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify schema
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected default schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
if len(schema.Scripts) != 1 {
|
||||
t.Fatalf("Expected 1 script, got %d", len(schema.Scripts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_InvalidDirectory(t *testing.T) {
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/directory",
|
||||
})
|
||||
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent directory, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_EmptyDirectory(t *testing.T) {
|
||||
// Create temporary empty directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
if len(db.Schemas[0].Scripts) != 0 {
|
||||
t.Errorf("Expected 0 scripts in empty directory, got %d", len(db.Schemas[0].Scripts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_InvalidFilename(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create files with various invalid patterns
|
||||
invalidFiles := []string{
|
||||
"invalid.sql", // No priority/sequence
|
||||
"1_test.sql", // Missing sequence
|
||||
"test_1_2.sql", // Wrong order
|
||||
"a_001_test.sql", // Non-numeric priority
|
||||
"1_abc_test.sql", // Non-numeric sequence
|
||||
"1_001_test.txt", // Wrong extension
|
||||
"1_001_test.sql.backup", // Wrong extension
|
||||
}
|
||||
|
||||
for _, filename := range invalidFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte("SELECT 1;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create one valid file
|
||||
validFile := filepath.Join(tempDir, "1_001_valid.sql")
|
||||
if err := os.WriteFile(validFile, []byte("SELECT 1;"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create valid file: %v", err)
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
// Should only have the valid file
|
||||
if len(db.Schemas[0].Scripts) != 1 {
|
||||
t.Errorf("Expected 1 script (invalid files should be skipped), got %d", len(db.Schemas[0].Scripts))
|
||||
}
|
||||
|
||||
if db.Schemas[0].Scripts[0].Name != "valid" {
|
||||
t.Errorf("Expected script name 'valid', got '%s'", db.Schemas[0].Scripts[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
reader := NewReader(&readers.ReaderOptions{})
|
||||
|
||||
_, err := reader.ReadTable()
|
||||
if err == nil {
|
||||
t.Error("Expected error for ReadTable (not supported), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_HyphenFormat(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-hyphen-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test files with hyphen separators
|
||||
testFiles := map[string]string{
|
||||
"1-001-create-table.sql": "CREATE TABLE test (id INT);",
|
||||
"1-002-insert-data.pgsql": "INSERT INTO test VALUES (1);",
|
||||
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL);",
|
||||
"2-005-add-index.sql": "CREATE INDEX idx_test ON test(id);",
|
||||
}
|
||||
|
||||
for filename, content := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, 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]
|
||||
if len(schema.Scripts) != 4 {
|
||||
t.Fatalf("Expected 4 scripts, got %d", len(schema.Scripts))
|
||||
}
|
||||
|
||||
// Verify specific hyphen-formatted scripts
|
||||
expectedScripts := map[string]struct {
|
||||
priority int
|
||||
sequence uint
|
||||
}{
|
||||
"create-table": {1, 1},
|
||||
"insert-data": {1, 2},
|
||||
"add-index": {2, 5},
|
||||
"create-newid": {10, 10},
|
||||
}
|
||||
|
||||
for _, script := range schema.Scripts {
|
||||
expected, exists := expectedScripts[script.Name]
|
||||
if !exists {
|
||||
t.Errorf("Unexpected script: %s", script.Name)
|
||||
continue
|
||||
}
|
||||
if script.Priority != expected.priority {
|
||||
t.Errorf("Script '%s': expected priority %d, got %d",
|
||||
script.Name, expected.priority, script.Priority)
|
||||
}
|
||||
if script.Sequence != expected.sequence {
|
||||
t.Errorf("Script '%s': expected sequence %d, got %d",
|
||||
script.Name, expected.sequence, script.Sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_MixedFormat(t *testing.T) {
|
||||
// Test that both underscore and hyphen formats can be mixed
|
||||
tempDir, err := os.MkdirTemp("", "sqldir-test-mixed-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
testFiles := map[string]string{
|
||||
"1_001_underscore.sql": "SELECT 1;",
|
||||
"1-002-hyphen.sql": "SELECT 2;",
|
||||
"2_003_underscore.sql": "SELECT 3;",
|
||||
"2-004-hyphen.sql": "SELECT 4;",
|
||||
}
|
||||
|
||||
for filename, content := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: tempDir,
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if len(schema.Scripts) != 4 {
|
||||
t.Fatalf("Expected 4 scripts (mixed format), got %d", len(schema.Scripts))
|
||||
}
|
||||
|
||||
// Verify both formats are parsed correctly
|
||||
names := make(map[string]bool)
|
||||
for _, script := range schema.Scripts {
|
||||
names[script.Name] = true
|
||||
}
|
||||
|
||||
expectedNames := []string{"underscore", "hyphen", "underscore", "hyphen"}
|
||||
for _, name := range expectedNames {
|
||||
if !names[name] {
|
||||
t.Errorf("Expected script name '%s' not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
326
pkg/reflectutil/helpers.go
Normal file
326
pkg/reflectutil/helpers.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package reflectutil
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Deref dereferences pointers until it reaches a non-pointer value
|
||||
// Returns the dereferenced value and true if successful, or the original value and false if nil
|
||||
func Deref(v reflect.Value) (reflect.Value, bool) {
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return v, false
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// DerefInterface dereferences an interface{} until it reaches a non-pointer value
|
||||
func DerefInterface(i interface{}) reflect.Value {
|
||||
v := reflect.ValueOf(i)
|
||||
v, _ = Deref(v)
|
||||
return v
|
||||
}
|
||||
|
||||
// GetFieldValue extracts a field value from a struct, map, or pointer
|
||||
// Returns nil if the field doesn't exist or can't be accessed
|
||||
func GetFieldValue(item interface{}, field string) interface{} {
|
||||
v := reflect.ValueOf(item)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.Struct:
|
||||
fieldVal := v.FieldByName(field)
|
||||
if fieldVal.IsValid() {
|
||||
return fieldVal.Interface()
|
||||
}
|
||||
return nil
|
||||
|
||||
case reflect.Map:
|
||||
keyVal := reflect.ValueOf(field)
|
||||
mapVal := v.MapIndex(keyVal)
|
||||
if mapVal.IsValid() {
|
||||
return mapVal.Interface()
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsSliceOrArray checks if an interface{} is a slice or array
|
||||
func IsSliceOrArray(i interface{}) bool {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
k := v.Kind()
|
||||
return k == reflect.Slice || k == reflect.Array
|
||||
}
|
||||
|
||||
// IsMap checks if an interface{} is a map
|
||||
func IsMap(i interface{}) bool {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return v.Kind() == reflect.Map
|
||||
}
|
||||
|
||||
// SliceLen returns the length of a slice/array, or 0 if not a slice/array
|
||||
func SliceLen(i interface{}) int {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return 0
|
||||
}
|
||||
return v.Len()
|
||||
}
|
||||
|
||||
// MapLen returns the length of a map, or 0 if not a map
|
||||
func MapLen(i interface{}) int {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if v.Kind() != reflect.Map {
|
||||
return 0
|
||||
}
|
||||
return v.Len()
|
||||
}
|
||||
|
||||
// SliceToInterfaces converts a slice/array to []interface{}
|
||||
// Returns empty slice if not a slice/array
|
||||
func SliceToInterfaces(i interface{}) []interface{} {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
result := make([]interface{}, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MapKeys returns all keys from a map as []interface{}
|
||||
// Returns empty slice if not a map
|
||||
func MapKeys(i interface{}) []interface{} {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
keys := v.MapKeys()
|
||||
result := make([]interface{}, len(keys))
|
||||
for i, key := range keys {
|
||||
result[i] = key.Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MapValues returns all values from a map as []interface{}
|
||||
// Returns empty slice if not a map
|
||||
func MapValues(i interface{}) []interface{} {
|
||||
v := reflect.ValueOf(i)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
result := make([]interface{}, 0, v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
result = append(result, iter.Value().Interface())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MapGet safely gets a value from a map by key
|
||||
// Returns nil if key doesn't exist or not a map
|
||||
func MapGet(m interface{}, key interface{}) interface{} {
|
||||
v := reflect.ValueOf(m)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyVal := reflect.ValueOf(key)
|
||||
mapVal := v.MapIndex(keyVal)
|
||||
if mapVal.IsValid() {
|
||||
return mapVal.Interface()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SliceIndex safely gets an element from a slice/array by index
|
||||
// Returns nil if index out of bounds or not a slice/array
|
||||
func SliceIndex(slice interface{}, index int) interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return nil
|
||||
}
|
||||
|
||||
if index < 0 || index >= v.Len() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v.Index(index).Interface()
|
||||
}
|
||||
|
||||
// CompareValues compares two values for sorting
|
||||
// Returns -1 if a < b, 0 if a == b, 1 if a > b
|
||||
func CompareValues(a, b interface{}) int {
|
||||
if a == nil && b == nil {
|
||||
return 0
|
||||
}
|
||||
if a == nil {
|
||||
return -1
|
||||
}
|
||||
if b == nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
va := reflect.ValueOf(a)
|
||||
vb := reflect.ValueOf(b)
|
||||
|
||||
// Handle different types
|
||||
switch va.Kind() {
|
||||
case reflect.String:
|
||||
if vb.Kind() == reflect.String {
|
||||
as := va.String()
|
||||
bs := vb.String()
|
||||
if as < bs {
|
||||
return -1
|
||||
} else if as > bs {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if vb.Kind() >= reflect.Int && vb.Kind() <= reflect.Int64 {
|
||||
ai := va.Int()
|
||||
bi := vb.Int()
|
||||
if ai < bi {
|
||||
return -1
|
||||
} else if ai > bi {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if vb.Kind() >= reflect.Uint && vb.Kind() <= reflect.Uint64 {
|
||||
au := va.Uint()
|
||||
bu := vb.Uint()
|
||||
if au < bu {
|
||||
return -1
|
||||
} else if au > bu {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if vb.Kind() == reflect.Float32 || vb.Kind() == reflect.Float64 {
|
||||
af := va.Float()
|
||||
bf := vb.Float()
|
||||
if af < bf {
|
||||
return -1
|
||||
} else if af > bf {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetNestedValue gets a nested value using dot notation path
|
||||
// Example: GetNestedValue(obj, "database.schema.table")
|
||||
func GetNestedValue(m interface{}, path string) interface{} {
|
||||
if path == "" {
|
||||
return m
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := m
|
||||
|
||||
for _, part := range parts {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(current)
|
||||
v, ok := Deref(v)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
keyVal := reflect.ValueOf(part)
|
||||
mapVal := v.MapIndex(keyVal)
|
||||
if !mapVal.IsValid() {
|
||||
return nil
|
||||
}
|
||||
current = mapVal.Interface()
|
||||
|
||||
case reflect.Struct:
|
||||
fieldVal := v.FieldByName(part)
|
||||
if !fieldVal.IsValid() {
|
||||
return nil
|
||||
}
|
||||
current = fieldVal.Interface()
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// DeepEqual performs a deep equality check between two values
|
||||
func DeepEqual(a, b interface{}) bool {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -127,6 +127,51 @@ func (w *Writer) databaseToDrawDB(d *models.Database) *DrawDBSchema {
|
||||
}
|
||||
}
|
||||
|
||||
// Create subject areas for domains
|
||||
for domainIdx, domainModel := range d.Domains {
|
||||
// Calculate bounds for all tables in this domain
|
||||
minX, minY := 999999, 999999
|
||||
maxX, maxY := 0, 0
|
||||
|
||||
domainTableCount := 0
|
||||
for _, domainTable := range domainModel.Tables {
|
||||
// Find the table in the schema to get its position
|
||||
for _, t := range schema.Tables {
|
||||
if t.Name == domainTable.TableName {
|
||||
if t.X < minX {
|
||||
minX = t.X
|
||||
}
|
||||
if t.Y < minY {
|
||||
minY = t.Y
|
||||
}
|
||||
if t.X+colWidth > maxX {
|
||||
maxX = t.X + colWidth
|
||||
}
|
||||
if t.Y+rowHeight > maxY {
|
||||
maxY = t.Y + rowHeight
|
||||
}
|
||||
domainTableCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only create area if domain has tables in this schema
|
||||
if domainTableCount > 0 {
|
||||
area := &DrawDBArea{
|
||||
ID: areaID,
|
||||
Name: domainModel.Name,
|
||||
Color: getColorForIndex(len(d.Schemas) + domainIdx), // Use different colors than schemas
|
||||
X: minX - 20,
|
||||
Y: minY - 20,
|
||||
Width: maxX - minX + 40,
|
||||
Height: maxY - minY + 40,
|
||||
}
|
||||
schema.SubjectAreas = append(schema.SubjectAreas, area)
|
||||
areaID++
|
||||
}
|
||||
}
|
||||
|
||||
// Add relationships
|
||||
for _, schemaModel := range d.Schemas {
|
||||
for _, table := range schemaModel.Tables {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
226
pkg/writers/sqlexec/README.md
Normal file
226
pkg/writers/sqlexec/README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# SQL Executor Writer
|
||||
|
||||
The SQL Executor Writer (`sqlexec`) executes SQL scripts from `models.Script` objects against a PostgreSQL database. Scripts are executed in order based on Priority (ascending) and Sequence (ascending).
|
||||
|
||||
## Features
|
||||
|
||||
- **Ordered Execution**: Scripts execute in Priority→Sequence 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
|
||||
- **Multiple Schemas**: Can execute scripts from multiple schemas in a database
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
)
|
||||
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://user:password@localhost:5432/dbname?sslmode=disable",
|
||||
},
|
||||
})
|
||||
|
||||
// Execute all scripts from database
|
||||
err := writer.WriteDatabase(database)
|
||||
if err != nil {
|
||||
log.Fatalf("Execution failed: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Execute Single Schema
|
||||
|
||||
```go
|
||||
err := writer.WriteSchema(schema)
|
||||
if err != nil {
|
||||
log.Fatalf("Schema execution failed: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example with SQL Directory Reader
|
||||
|
||||
```go
|
||||
import (
|
||||
"log"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read SQL scripts from directory
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "./migrations",
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Execute scripts against PostgreSQL
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/myapp",
|
||||
},
|
||||
})
|
||||
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Metadata
|
||||
|
||||
- **connection_string**: PostgreSQL connection string (required)
|
||||
|
||||
### Connection String Format
|
||||
|
||||
```
|
||||
postgres://[user[:password]@][host][:port][/dbname][?param1=value1&...]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
postgres://localhost/mydb
|
||||
postgres://user:pass@localhost:5432/mydb?sslmode=disable
|
||||
postgres://user@localhost/mydb?sslmode=require
|
||||
postgresql://user:pass@prod-db.example.com:5432/production
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
Execution order: **C → D → B → A → E**
|
||||
|
||||
## Output
|
||||
|
||||
The writer prints progress to stdout:
|
||||
|
||||
```
|
||||
Executing script: create_users (Priority=1, Sequence=1)
|
||||
✓ Successfully executed: create_users
|
||||
Executing script: create_posts (Priority=1, Sequence=2)
|
||||
✓ Successfully executed: create_posts
|
||||
Executing script: add_indexes (Priority=2, Sequence=1)
|
||||
✓ Successfully executed: add_indexes
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Connection Errors
|
||||
|
||||
If the database connection fails, execution stops immediately:
|
||||
|
||||
```
|
||||
Error: failed to connect to database: connection refused
|
||||
```
|
||||
|
||||
### Script Execution Errors
|
||||
|
||||
If a script fails, execution stops and returns the error with context:
|
||||
|
||||
```
|
||||
Error: failed to execute script add_indexes (Priority=2, Sequence=1):
|
||||
syntax error at or near "IDNEX"
|
||||
```
|
||||
|
||||
**Behavior**: Stop on first error (scripts executed before the error remain committed)
|
||||
|
||||
### Empty Script Handling
|
||||
|
||||
Scripts with empty SQL content are skipped silently.
|
||||
|
||||
## Database Support
|
||||
|
||||
Currently supports:
|
||||
- ✅ PostgreSQL (via pgx/v5)
|
||||
|
||||
Future support planned for:
|
||||
- MySQL/MariaDB
|
||||
- SQLite
|
||||
- Generic SQL via database/sql
|
||||
|
||||
## Transaction Behavior
|
||||
|
||||
**Current**: Each script executes in its own implicit transaction (PostgreSQL default behavior)
|
||||
|
||||
**Future Enhancement**: Option to wrap all scripts in a single transaction for atomic execution with rollback on error.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Scripts execute sequentially (not in parallel)
|
||||
- Each script creates a database round-trip
|
||||
- For large migrations, consider:
|
||||
- Combining related statements into fewer scripts
|
||||
- Using PostgreSQL's COPY command for bulk data
|
||||
- Running during low-traffic periods
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
go test ./pkg/writers/sqlexec/
|
||||
```
|
||||
|
||||
Current tests include:
|
||||
- Validation and error handling
|
||||
- Script sorting logic
|
||||
- Configuration validation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
For integration testing with a real database:
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL (example with Docker)
|
||||
docker run -d --name postgres-test \
|
||||
-e POSTGRES_PASSWORD=test \
|
||||
-e POSTGRES_DB=testdb \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
|
||||
# Run your integration tests
|
||||
go test -tags=integration ./pkg/writers/sqlexec/
|
||||
|
||||
# Cleanup
|
||||
docker stop postgres-test
|
||||
docker rm postgres-test
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- `WriteTable()` is not supported (returns error)
|
||||
- Requires PostgreSQL connection (no offline mode)
|
||||
- No built-in transaction wrapping (yet)
|
||||
- No rollback script support (yet, though `models.Script.Rollback` field exists)
|
||||
|
||||
## Related
|
||||
|
||||
- **SQL Directory Reader**: `pkg/readers/sqldir/` - Read scripts from filesystem
|
||||
- **Script Model**: `pkg/models/models.go` - Script structure definition
|
||||
- **pgx Documentation**: https://github.com/jackc/pgx - PostgreSQL driver docs
|
||||
125
pkg/writers/sqlexec/writer.go
Normal file
125
pkg/writers/sqlexec/writer.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package sqlexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// Writer implements the writers.Writer interface for executing SQL scripts
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
}
|
||||
|
||||
// NewWriter creates a new SQL executor writer
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
return &Writer{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteDatabase executes all scripts from all schemas in the database
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is nil")
|
||||
}
|
||||
|
||||
// Get connection string from metadata
|
||||
connString, ok := w.options.Metadata["connection_string"].(string)
|
||||
if !ok || connString == "" {
|
||||
return fmt.Errorf("connection_string is required in writer metadata")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Execute scripts from all schemas
|
||||
for _, schema := range db.Schemas {
|
||||
if err := w.executeScripts(ctx, conn, schema.Scripts); err != nil {
|
||||
return fmt.Errorf("failed to execute scripts from schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSchema executes all scripts from a single schema
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
if schema == nil {
|
||||
return fmt.Errorf("schema is nil")
|
||||
}
|
||||
|
||||
// Get connection string from metadata
|
||||
connString, ok := w.options.Metadata["connection_string"].(string)
|
||||
if !ok || connString == "" {
|
||||
return fmt.Errorf("connection_string is required in writer metadata")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Execute scripts
|
||||
if err := w.executeScripts(ctx, conn, schema.Scripts); err != nil {
|
||||
return fmt.Errorf("failed to execute scripts: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteTable is not applicable for SQL script execution
|
||||
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
|
||||
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)
|
||||
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
|
||||
})
|
||||
|
||||
// Execute each script in order
|
||||
for _, script := range sortedScripts {
|
||||
if script.SQL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
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",
|
||||
script.Name, script.Priority, script.Sequence, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Successfully executed: %s\n", script.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
201
pkg/writers/sqlexec/writer_test.go
Normal file
201
pkg/writers/sqlexec/writer_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package sqlexec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestNewWriter(t *testing.T) {
|
||||
opts := &writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/test",
|
||||
},
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
if writer == nil {
|
||||
t.Fatal("Expected non-nil writer")
|
||||
}
|
||||
if writer.options != opts {
|
||||
t.Error("Writer options not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_NilDatabase(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/test",
|
||||
},
|
||||
})
|
||||
|
||||
err := writer.WriteDatabase(nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil database, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_MissingConnectionString(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
|
||||
db := &models.Database{
|
||||
Name: "test",
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "public",
|
||||
Scripts: []*models.Script{
|
||||
{Name: "test", SQL: "SELECT 1;"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing connection_string, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteSchema_NilSchema(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://localhost/test",
|
||||
},
|
||||
})
|
||||
|
||||
err := writer.WriteSchema(nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil schema, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteSchema_MissingConnectionString(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
|
||||
schema := &models.Schema{
|
||||
Name: "public",
|
||||
Scripts: []*models.Script{
|
||||
{Name: "test", SQL: "SELECT 1;"},
|
||||
},
|
||||
}
|
||||
|
||||
err := writer.WriteSchema(schema)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing connection_string, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteTable(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
err := writer.WriteTable(&models.Table{})
|
||||
if err == nil {
|
||||
t.Error("Expected error for WriteTable (not supported), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptSorting verifies that scripts are sorted correctly by Priority then Sequence
|
||||
func TestScriptSorting(t *testing.T) {
|
||||
scripts := []*models.Script{
|
||||
{Name: "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: "script5", Priority: 3, Sequence: 1, SQL: "SELECT 5;"},
|
||||
{Name: "script6", Priority: 2, Sequence: 2, SQL: "SELECT 6;"},
|
||||
}
|
||||
|
||||
// Create a copy and sort it using the same logic as executeScripts
|
||||
sortedScripts := make([]*models.Script, len(scripts))
|
||||
copy(sortedScripts, scripts)
|
||||
|
||||
// Use the same sorting logic from executeScripts
|
||||
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) {
|
||||
sortedScripts[i], sortedScripts[j] = sortedScripts[j], sortedScripts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expected order after sorting
|
||||
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
|
||||
}
|
||||
|
||||
for i, expected := range expectedOrder {
|
||||
if sortedScripts[i].Name != expected {
|
||||
t.Errorf("Position %d: expected %s, got %s", i, expected, sortedScripts[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify priorities are ascending
|
||||
for i := 0; i < len(sortedScripts)-1; i++ {
|
||||
if sortedScripts[i].Priority > sortedScripts[i+1].Priority {
|
||||
t.Errorf("Priority not ascending at position %d: %d > %d",
|
||||
i, sortedScripts[i].Priority, sortedScripts[i+1].Priority)
|
||||
}
|
||||
// Within same priority, sequences should be ascending
|
||||
if sortedScripts[i].Priority == sortedScripts[i+1].Priority &&
|
||||
sortedScripts[i].Sequence > sortedScripts[i+1].Sequence {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteSchema_EmptyScripts(t *testing.T) {
|
||||
// This test verifies that writing an empty script list doesn't cause errors
|
||||
// even without a database connection (should return early)
|
||||
writer := NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": "postgres://invalid/test",
|
||||
},
|
||||
})
|
||||
|
||||
schema := &models.Schema{
|
||||
Name: "public",
|
||||
Scripts: []*models.Script{},
|
||||
}
|
||||
|
||||
// Note: This will try to connect even with empty scripts
|
||||
// In a real scenario, the executeScripts function returns early for empty scripts
|
||||
// but the connection is made before that. This test documents the behavior.
|
||||
err := writer.WriteSchema(schema)
|
||||
// We expect a connection error since we're using an invalid connection string
|
||||
if err == nil {
|
||||
t.Error("Expected connection error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Integration tests for actual database execution should be added separately
|
||||
// Those tests would require:
|
||||
// 1. A running PostgreSQL instance
|
||||
// 2. Test database setup/teardown
|
||||
// 3. Verification of actual script execution
|
||||
// 4. Testing error handling during execution
|
||||
// 5. Testing transaction behavior if added
|
||||
//
|
||||
// Example integration test structure:
|
||||
// func TestWriter_Integration_ExecuteScripts(t *testing.T) {
|
||||
// if testing.Short() {
|
||||
// t.Skip("Skipping integration test")
|
||||
// }
|
||||
// // Setup test database
|
||||
// // Create test scripts
|
||||
// // Execute scripts
|
||||
// // Verify results
|
||||
// // Cleanup
|
||||
// }
|
||||
276
pkg/writers/template/README.md
Normal file
276
pkg/writers/template/README.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Template Writer
|
||||
|
||||
Custom template-based writer for RelSpec that allows users to generate any output format using Go text templates.
|
||||
|
||||
## Overview
|
||||
|
||||
The template writer provides a powerful and flexible way to transform database schemas into any desired format. It supports multiple execution modes and provides 80+ template functions for data transformation.
|
||||
|
||||
**For complete user documentation, see:** [/docs/TEMPLATE_MODE.md](../../../docs/TEMPLATE_MODE.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
pkg/writers/template/
|
||||
├── README.md # This file
|
||||
├── writer.go # Core writer with entrypoint mode logic
|
||||
├── template_data.go # Data structures passed to templates
|
||||
├── funcmap.go # Template function registry
|
||||
├── string_helpers.go # String manipulation functions
|
||||
├── type_mappers.go # SQL type conversion (delegates to commontypes)
|
||||
├── filters.go # Database object filtering
|
||||
├── formatters.go # JSON/YAML formatting utilities
|
||||
├── loop_helpers.go # Iteration and collection utilities
|
||||
├── safe_access.go # Safe map/array access functions
|
||||
└── errors.go # Custom error types
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **`pkg/commontypes`** - Centralized type mappings for Go, TypeScript, Java, Python, Rust, C#, PHP
|
||||
- **`pkg/reflectutil`** - Reflection utilities for safe type manipulation
|
||||
- **`pkg/models`** - Database schema models
|
||||
|
||||
## Writer Interface Implementation
|
||||
|
||||
Implements the standard `writers.Writer` interface:
|
||||
|
||||
```go
|
||||
type Writer interface {
|
||||
WriteDatabase(db *models.Database) error
|
||||
WriteSchema(schema *models.Schema) error
|
||||
WriteTable(table *models.Table) error
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Modes
|
||||
|
||||
The writer supports four execution modes via `WriterOptions.Metadata["mode"]`:
|
||||
|
||||
| Mode | Data Passed | Output | Use Case |
|
||||
|------|-------------|--------|----------|
|
||||
| `database` | Full database | Single file | Reports, documentation |
|
||||
| `schema` | One schema at a time | File per schema | Schema-specific docs |
|
||||
| `script` | One script at a time | File per script | Script processing |
|
||||
| `table` | One table at a time | File per table | Model generation |
|
||||
|
||||
## Configuration
|
||||
|
||||
Writer is configured via `WriterOptions.Metadata`:
|
||||
|
||||
```go
|
||||
metadata := map[string]interface{}{
|
||||
"template_path": "/path/to/template.tmpl", // Required
|
||||
"mode": "table", // Default: "database"
|
||||
"filename_pattern": "{{.Name}}.ts", // Default: "{{.Name}}.txt"
|
||||
}
|
||||
```
|
||||
|
||||
## Template Data Structure
|
||||
|
||||
Templates receive a `TemplateData` struct:
|
||||
|
||||
```go
|
||||
type TemplateData struct {
|
||||
// Primary data (one populated based on mode)
|
||||
Database *models.Database
|
||||
Schema *models.Schema
|
||||
Script *models.Script
|
||||
Table *models.Table
|
||||
|
||||
// Parent context
|
||||
ParentSchema *models.Schema
|
||||
ParentDatabase *models.Database
|
||||
|
||||
// Pre-computed views
|
||||
FlatColumns []*models.FlatColumn
|
||||
FlatTables []*models.FlatTable
|
||||
FlatConstraints []*models.FlatConstraint
|
||||
FlatRelationships []*models.FlatRelationship
|
||||
Summary *models.DatabaseSummary
|
||||
|
||||
// User metadata
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
```
|
||||
|
||||
## Function Categories
|
||||
|
||||
### String Utilities (string_helpers.go)
|
||||
Case conversion, pluralization, trimming, splitting, joining
|
||||
|
||||
### Type Mappers (type_mappers.go)
|
||||
SQL type conversion to 7+ programming languages (delegates to `pkg/commontypes`)
|
||||
|
||||
### Filters (filters.go)
|
||||
Database object filtering by pattern, type, constraints
|
||||
|
||||
### Formatters (formatters.go)
|
||||
JSON/YAML serialization, indentation, escaping, commenting
|
||||
|
||||
### Loop Helpers (loop_helpers.go)
|
||||
Enumeration, batching, reversing, sorting, grouping (uses `pkg/reflectutil`)
|
||||
|
||||
### Safe Access (safe_access.go)
|
||||
Safe map/array access without panics (uses `pkg/reflectutil`)
|
||||
|
||||
## Adding New Functions
|
||||
|
||||
To add a new template function:
|
||||
|
||||
1. **Implement the function** in the appropriate file:
|
||||
```go
|
||||
// string_helpers.go
|
||||
func ToScreamingSnakeCase(s string) string {
|
||||
return strings.ToUpper(ToSnakeCase(s))
|
||||
}
|
||||
```
|
||||
|
||||
2. **Register in funcmap.go:**
|
||||
```go
|
||||
func BuildFuncMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// ... existing functions
|
||||
"toScreamingSnakeCase": ToScreamingSnakeCase,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Document in /docs/TEMPLATE_MODE.md**
|
||||
|
||||
## Error Handling
|
||||
|
||||
Custom error types in `errors.go`:
|
||||
|
||||
- `TemplateLoadError` - Template file not found or unreadable
|
||||
- `TemplateParseError` - Invalid template syntax
|
||||
- `TemplateExecuteError` - Error during template execution
|
||||
|
||||
All errors wrap the underlying error for context.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
go test ./pkg/writers/template/...
|
||||
|
||||
# Test with example data
|
||||
cat > test.tmpl << 'EOF'
|
||||
{{ range .Database.Schemas }}
|
||||
Schema: {{ .Name }} ({{ len .Tables }} tables)
|
||||
{{ end }}
|
||||
EOF
|
||||
|
||||
relspec templ --from json --from-path schema.json --template test.tmpl
|
||||
```
|
||||
|
||||
## Multi-file Output
|
||||
|
||||
For multi-file modes, the writer:
|
||||
|
||||
1. Iterates through items (schemas/scripts/tables)
|
||||
2. Creates `TemplateData` for each item
|
||||
3. Executes template with item data
|
||||
4. Generates filename using `filename_pattern` template
|
||||
5. Writes output to generated filename
|
||||
|
||||
Output directory is created automatically if it doesn't exist.
|
||||
|
||||
## Filename Pattern Execution
|
||||
|
||||
The filename pattern is itself a template:
|
||||
|
||||
```go
|
||||
// Pattern: "{{.Schema}}/{{.Name | toCamelCase}}.ts"
|
||||
// For table "user_profile" in schema "public"
|
||||
// Generates: "public/userProfile.ts"
|
||||
```
|
||||
|
||||
Available in pattern template:
|
||||
- `.Name` - Item name (schema/script/table)
|
||||
- `.Schema` - Schema name (for scripts/tables)
|
||||
- All template functions
|
||||
|
||||
## Example Usage
|
||||
|
||||
### As a Library
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/template"
|
||||
)
|
||||
|
||||
// Create writer
|
||||
metadata := map[string]interface{}{
|
||||
"template_path": "model.tmpl",
|
||||
"mode": "table",
|
||||
"filename_pattern": "{{.Name}}.go",
|
||||
}
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "./models/",
|
||||
PackageName: "models",
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
writer, err := template.NewWriter(opts)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Write database
|
||||
err = writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
relspec templ \
|
||||
--from pgsql \
|
||||
--from-conn "postgres://localhost/mydb" \
|
||||
--template model.tmpl \
|
||||
--mode table \
|
||||
--output ./models/ \
|
||||
--filename-pattern "{{.Name | toPascalCase}}.go"
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Template Parsing** - Template is parsed once in `NewWriter()`, not per execution
|
||||
2. **Reflection** - Loop and safe access helpers use reflection; cached where possible
|
||||
3. **Pre-computed Views** - `FlatColumns`, `FlatTables`, etc. computed once per data item
|
||||
4. **File I/O** - Multi-file mode creates directories as needed
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
|
||||
- [ ] Template caching for filename patterns
|
||||
- [ ] Parallel template execution for multi-file mode
|
||||
- [ ] Template function plugins
|
||||
- [ ] Custom function injection via metadata
|
||||
- [ ] Template includes/partials support
|
||||
- [ ] Dry-run mode to preview filenames
|
||||
- [ ] Progress reporting for large schemas
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. Follow existing patterns (see similar functions)
|
||||
2. Add to appropriate category file
|
||||
3. Register in `funcmap.go`
|
||||
4. Update `/docs/TEMPLATE_MODE.md`
|
||||
5. Add tests
|
||||
6. Consider edge cases (nil, empty, invalid input)
|
||||
|
||||
## See Also
|
||||
|
||||
- [User Documentation](/docs/TEMPLATE_MODE.md) - Complete template function reference
|
||||
- [Common Types Package](../../commontypes/) - Centralized type mappings
|
||||
- [Reflect Utilities](../../reflectutil/) - Reflection helpers
|
||||
- [Models Package](../../models/) - Database schema models
|
||||
- [Go Template Docs](https://pkg.go.dev/text/template) - Official Go template documentation
|
||||
50
pkg/writers/template/errors.go
Normal file
50
pkg/writers/template/errors.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package template
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TemplateError represents an error that occurred during template operations
|
||||
type TemplateError struct {
|
||||
Phase string // "load", "parse", "execute"
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *TemplateError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("template %s error: %s: %v", e.Phase, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("template %s error: %s", e.Phase, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped error
|
||||
func (e *TemplateError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewTemplateLoadError creates a new template load error
|
||||
func NewTemplateLoadError(msg string, err error) *TemplateError {
|
||||
return &TemplateError{
|
||||
Phase: "load",
|
||||
Message: msg,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTemplateParseError creates a new template parse error
|
||||
func NewTemplateParseError(msg string, err error) *TemplateError {
|
||||
return &TemplateError{
|
||||
Phase: "parse",
|
||||
Message: msg,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTemplateExecuteError creates a new template execution error
|
||||
func NewTemplateExecuteError(msg string, err error) *TemplateError {
|
||||
return &TemplateError{
|
||||
Phase: "execute",
|
||||
Message: msg,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
144
pkg/writers/template/filters.go
Normal file
144
pkg/writers/template/filters.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/commontypes"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// FilterTables filters tables using a predicate function
|
||||
// Usage: {{ $filtered := filterTables .Schema.Tables (func $t) { return hasPrefix $t.Name "user_" } }}
|
||||
// Note: Template functions can't pass Go funcs, so this is primarily for internal use
|
||||
func FilterTables(tables []*models.Table, pattern string) []*models.Table {
|
||||
if pattern == "" {
|
||||
return tables
|
||||
}
|
||||
|
||||
result := make([]*models.Table, 0)
|
||||
for _, table := range tables {
|
||||
if matchPattern(table.Name, pattern) {
|
||||
result = append(result, table)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterTablesByPattern filters tables by name pattern (glob-style)
|
||||
// Usage: {{ $userTables := filterTablesByPattern .Schema.Tables "user_*" }}
|
||||
func FilterTablesByPattern(tables []*models.Table, pattern string) []*models.Table {
|
||||
return FilterTables(tables, pattern)
|
||||
}
|
||||
|
||||
// FilterColumns filters columns from a map using a pattern
|
||||
// Usage: {{ $filtered := filterColumns .Table.Columns "*_id" }}
|
||||
func FilterColumns(columns map[string]*models.Column, pattern string) []*models.Column {
|
||||
result := make([]*models.Column, 0)
|
||||
for _, col := range columns {
|
||||
if pattern == "" || matchPattern(col.Name, pattern) {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterColumnsByType filters columns by SQL type
|
||||
// Usage: {{ $stringCols := filterColumnsByType .Table.Columns "varchar" }}
|
||||
func FilterColumnsByType(columns map[string]*models.Column, sqlType string) []*models.Column {
|
||||
result := make([]*models.Column, 0)
|
||||
baseType := commontypes.ExtractBaseType(sqlType)
|
||||
|
||||
for _, col := range columns {
|
||||
colBaseType := commontypes.ExtractBaseType(col.Type)
|
||||
if colBaseType == baseType {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterPrimaryKeys returns only columns that are primary keys
|
||||
// Usage: {{ $pks := filterPrimaryKeys .Table.Columns }}
|
||||
func FilterPrimaryKeys(columns map[string]*models.Column) []*models.Column {
|
||||
result := make([]*models.Column, 0)
|
||||
for _, col := range columns {
|
||||
if col.IsPrimaryKey {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterForeignKeys returns only foreign key constraints
|
||||
// Usage: {{ $fks := filterForeignKeys .Table.Constraints }}
|
||||
func FilterForeignKeys(constraints map[string]*models.Constraint) []*models.Constraint {
|
||||
result := make([]*models.Constraint, 0)
|
||||
for _, constraint := range constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
result = append(result, constraint)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterUniqueConstraints returns only unique constraints
|
||||
// Usage: {{ $uniques := filterUniqueConstraints .Table.Constraints }}
|
||||
func FilterUniqueConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
||||
result := make([]*models.Constraint, 0)
|
||||
for _, constraint := range constraints {
|
||||
if constraint.Type == models.UniqueConstraint {
|
||||
result = append(result, constraint)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterCheckConstraints returns only check constraints
|
||||
// Usage: {{ $checks := filterCheckConstraints .Table.Constraints }}
|
||||
func FilterCheckConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
||||
result := make([]*models.Constraint, 0)
|
||||
for _, constraint := range constraints {
|
||||
if constraint.Type == models.CheckConstraint {
|
||||
result = append(result, constraint)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterNullable returns only nullable columns
|
||||
// Usage: {{ $nullables := filterNullable .Table.Columns }}
|
||||
func FilterNullable(columns map[string]*models.Column) []*models.Column {
|
||||
result := make([]*models.Column, 0)
|
||||
for _, col := range columns {
|
||||
if !col.NotNull {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterNotNull returns only non-nullable columns
|
||||
// Usage: {{ $required := filterNotNull .Table.Columns }}
|
||||
func FilterNotNull(columns map[string]*models.Column) []*models.Column {
|
||||
result := make([]*models.Column, 0)
|
||||
for _, col := range columns {
|
||||
if col.NotNull {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// matchPattern performs simple glob-style pattern matching
|
||||
// Supports: * (any characters), ? (single character)
|
||||
// Examples: "user_*" matches "user_profile", "user_settings"
|
||||
func matchPattern(s, pattern string) bool {
|
||||
// Use filepath.Match for glob-style pattern matching
|
||||
matched, err := filepath.Match(pattern, s)
|
||||
if err != nil {
|
||||
// If pattern is invalid, do exact match
|
||||
return strings.EqualFold(s, pattern)
|
||||
}
|
||||
return matched
|
||||
}
|
||||
157
pkg/writers/template/formatters.go
Normal file
157
pkg/writers/template/formatters.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ToJSON converts a value to JSON string
|
||||
// Usage: {{ .Database | toJSON }}
|
||||
func ToJSON(v interface{}) string {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("{\"error\": \"failed to marshal: %v\"}", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ToJSONPretty converts a value to pretty-printed JSON string
|
||||
// Usage: {{ .Database | toJSONPretty " " }}
|
||||
func ToJSONPretty(v interface{}, indent string) string {
|
||||
data, err := json.MarshalIndent(v, "", indent)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("{\"error\": \"failed to marshal: %v\"}", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ToYAML converts a value to YAML string
|
||||
// Usage: {{ .Database | toYAML }}
|
||||
func ToYAML(v interface{}) string {
|
||||
data, err := yaml.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: failed to marshal: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// Indent indents each line of a string by the specified number of spaces
|
||||
// Usage: {{ .Column.Description | indent 4 }}
|
||||
func Indent(s string, spaces int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// IndentWith indents each line of a string with a custom prefix
|
||||
// Usage: {{ .Column.Description | indentWith " " }}
|
||||
func IndentWith(s string, prefix string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Escape escapes special characters in a string for use in code
|
||||
// Usage: {{ .Column.Default | escape }}
|
||||
func Escape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
return s
|
||||
}
|
||||
|
||||
// EscapeQuotes escapes only quote characters
|
||||
// Usage: {{ .Column.Comment | escapeQuotes }}
|
||||
func EscapeQuotes(s string) string {
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "'", "\\'")
|
||||
return s
|
||||
}
|
||||
|
||||
// Comment adds comment prefix to a string
|
||||
// Supports: "//" (Go, C++, etc.), "#" (Python, shell), "--" (SQL), "/* */" (block)
|
||||
// Usage: {{ .Table.Description | comment "//" }}
|
||||
func Comment(s string, style string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
|
||||
switch style {
|
||||
case "//":
|
||||
for i, line := range lines {
|
||||
lines[i] = "// " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
|
||||
case "#":
|
||||
for i, line := range lines {
|
||||
lines[i] = "# " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
|
||||
case "--":
|
||||
for i, line := range lines {
|
||||
lines[i] = "-- " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
|
||||
case "/* */", "/**/":
|
||||
if len(lines) == 1 {
|
||||
return "/* " + lines[0] + " */"
|
||||
}
|
||||
result := "/*\n"
|
||||
for _, line := range lines {
|
||||
result += " * " + line + "\n"
|
||||
}
|
||||
result += " */"
|
||||
return result
|
||||
|
||||
default:
|
||||
// Default to // style
|
||||
for i, line := range lines {
|
||||
lines[i] = "// " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// QuoteString adds quotes around a string
|
||||
// Usage: {{ .Column.Default | quoteString }}
|
||||
func QuoteString(s string) string {
|
||||
return "\"" + s + "\""
|
||||
}
|
||||
|
||||
// UnquoteString removes quotes from a string
|
||||
// Usage: {{ .Value | unquoteString }}
|
||||
func UnquoteString(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
171
pkg/writers/template/funcmap.go
Normal file
171
pkg/writers/template/funcmap.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"text/template"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// BuildFuncMap creates a template.FuncMap with all available helper functions
|
||||
func BuildFuncMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// String manipulation functions
|
||||
"toUpper": ToUpper,
|
||||
"toLower": ToLower,
|
||||
"toCamelCase": ToCamelCase,
|
||||
"toPascalCase": ToPascalCase,
|
||||
"toSnakeCase": ToSnakeCase,
|
||||
"toKebabCase": ToKebabCase,
|
||||
"pluralize": Pluralize,
|
||||
"singularize": Singularize,
|
||||
"title": Title,
|
||||
"trim": Trim,
|
||||
"trimPrefix": TrimPrefix,
|
||||
"trimSuffix": TrimSuffix,
|
||||
"replace": Replace,
|
||||
"stringContains": StringContains, // Avoid conflict with slice contains
|
||||
"hasPrefix": HasPrefix,
|
||||
"hasSuffix": HasSuffix,
|
||||
"split": Split,
|
||||
"join": Join,
|
||||
|
||||
// Type conversion functions
|
||||
"sqlToGo": SQLToGo,
|
||||
"sqlToTypeScript": SQLToTypeScript,
|
||||
"sqlToJava": SQLToJava,
|
||||
"sqlToPython": SQLToPython,
|
||||
"sqlToRust": SQLToRust,
|
||||
"sqlToCSharp": SQLToCSharp,
|
||||
"sqlToPhp": SQLToPhp,
|
||||
|
||||
// Filtering functions
|
||||
"filterTables": FilterTables,
|
||||
"filterTablesByPattern": FilterTablesByPattern,
|
||||
"filterColumns": FilterColumns,
|
||||
"filterColumnsByType": FilterColumnsByType,
|
||||
"filterPrimaryKeys": FilterPrimaryKeys,
|
||||
"filterForeignKeys": FilterForeignKeys,
|
||||
"filterUniqueConstraints": FilterUniqueConstraints,
|
||||
"filterCheckConstraints": FilterCheckConstraints,
|
||||
"filterNullable": FilterNullable,
|
||||
"filterNotNull": FilterNotNull,
|
||||
|
||||
// Formatting functions
|
||||
"toJSON": ToJSON,
|
||||
"toJSONPretty": ToJSONPretty,
|
||||
"toYAML": ToYAML,
|
||||
"indent": Indent,
|
||||
"indentWith": IndentWith,
|
||||
"escape": Escape,
|
||||
"escapeQuotes": EscapeQuotes,
|
||||
"comment": Comment,
|
||||
"quoteString": QuoteString,
|
||||
"unquoteString": UnquoteString,
|
||||
|
||||
// Loop/iteration helper functions
|
||||
"enumerate": Enumerate,
|
||||
"batch": Batch,
|
||||
"chunk": Chunk,
|
||||
"reverse": Reverse,
|
||||
"first": First,
|
||||
"last": Last,
|
||||
"skip": Skip,
|
||||
"take": Take,
|
||||
"concat": Concat,
|
||||
"unique": Unique,
|
||||
"sortBy": SortBy,
|
||||
"groupBy": GroupBy,
|
||||
|
||||
// Safe access functions
|
||||
"get": Get,
|
||||
"getOr": GetOr,
|
||||
"getPath": GetPath,
|
||||
"getPathOr": GetPathOr,
|
||||
"safeIndex": SafeIndex,
|
||||
"safeIndexOr": SafeIndexOr,
|
||||
"has": Has,
|
||||
"hasPath": HasPath,
|
||||
"keys": Keys,
|
||||
"values": Values,
|
||||
"merge": Merge,
|
||||
"pick": Pick,
|
||||
"omit": Omit,
|
||||
"sliceContains": SliceContains,
|
||||
"indexOf": IndexOf,
|
||||
"pluck": Pluck,
|
||||
|
||||
// Sorting functions
|
||||
"sortSchemasByName": models.SortSchemasByName,
|
||||
"sortSchemasBySequence": models.SortSchemasBySequence,
|
||||
"sortTablesByName": models.SortTablesByName,
|
||||
"sortTablesBySequence": models.SortTablesBySequence,
|
||||
"sortColumnsByName": models.SortColumnsByName,
|
||||
"sortColumnsBySequence": models.SortColumnsBySequence,
|
||||
"sortColumnsMapByName": models.SortColumnsMapByName,
|
||||
"sortColumnsMapBySequence": models.SortColumnsMapBySequence,
|
||||
"sortViewsByName": models.SortViewsByName,
|
||||
"sortViewsBySequence": models.SortViewsBySequence,
|
||||
"sortSequencesByName": models.SortSequencesByName,
|
||||
"sortSequencesBySequence": models.SortSequencesBySequence,
|
||||
"sortIndexesByName": models.SortIndexesByName,
|
||||
"sortIndexesBySequence": models.SortIndexesBySequence,
|
||||
"sortIndexesMapByName": models.SortIndexesMapByName,
|
||||
"sortIndexesMapBySequence": models.SortIndexesMapBySequence,
|
||||
"sortConstraintsByName": models.SortConstraintsByName,
|
||||
"sortConstraintsMapByName": models.SortConstraintsMapByName,
|
||||
"sortRelationshipsByName": models.SortRelationshipsByName,
|
||||
"sortRelationshipsMapByName": models.SortRelationshipsMapByName,
|
||||
"sortScriptsByName": models.SortScriptsByName,
|
||||
"sortEnumsByName": models.SortEnumsByName,
|
||||
|
||||
// Utility functions (built-in Go template helpers + custom)
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"div": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
},
|
||||
"mod": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a % b
|
||||
},
|
||||
"default": func(defaultVal, val interface{}) interface{} {
|
||||
if val == nil {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
},
|
||||
"dict": func(values ...interface{}) map[string]interface{} {
|
||||
if len(values)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
dict := make(map[string]interface{}, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
dict[key] = values[i+1]
|
||||
}
|
||||
return dict
|
||||
},
|
||||
"list": func(values ...interface{}) []interface{} {
|
||||
return values
|
||||
},
|
||||
"seq": func(start, end int) []int {
|
||||
if start > end {
|
||||
return []int{}
|
||||
}
|
||||
result := make([]int, end-start+1)
|
||||
for i := range result {
|
||||
result[i] = start + i
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
282
pkg/writers/template/loop_helpers.go
Normal file
282
pkg/writers/template/loop_helpers.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/reflectutil"
|
||||
)
|
||||
|
||||
// EnumeratedItem represents an item with its index
|
||||
type EnumeratedItem struct {
|
||||
Index int
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Enumerate returns a slice with index-value pairs
|
||||
// Usage: {{ range enumerate .Tables }}{{ .Index }}: {{ .Value.Name }}{{ end }}
|
||||
func Enumerate(slice interface{}) []EnumeratedItem {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []EnumeratedItem{}
|
||||
}
|
||||
|
||||
result := make([]EnumeratedItem, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = EnumeratedItem{
|
||||
Index: i,
|
||||
Value: v.Index(i).Interface(),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Batch splits a slice into chunks of specified size
|
||||
// Usage: {{ range batch .Columns 3 }}...{{ end }}
|
||||
func Batch(slice interface{}, size int) [][]interface{} {
|
||||
if size <= 0 {
|
||||
return [][]interface{}{}
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return [][]interface{}{}
|
||||
}
|
||||
|
||||
length := v.Len()
|
||||
if length == 0 {
|
||||
return [][]interface{}{}
|
||||
}
|
||||
|
||||
numBatches := (length + size - 1) / size
|
||||
result := make([][]interface{}, numBatches)
|
||||
|
||||
for i := 0; i < numBatches; i++ {
|
||||
start := i * size
|
||||
end := start + size
|
||||
if end > length {
|
||||
end = length
|
||||
}
|
||||
|
||||
batch := make([]interface{}, end-start)
|
||||
for j := start; j < end; j++ {
|
||||
batch[j-start] = v.Index(j).Interface()
|
||||
}
|
||||
result[i] = batch
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Chunk is an alias for Batch
|
||||
func Chunk(slice interface{}, size int) [][]interface{} {
|
||||
return Batch(slice, size)
|
||||
}
|
||||
|
||||
// Reverse reverses a slice
|
||||
// Usage: {{ range reverse .Tables }}...{{ end }}
|
||||
func Reverse(slice interface{}) []interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
length := v.Len()
|
||||
result := make([]interface{}, length)
|
||||
for i := 0; i < length; i++ {
|
||||
result[length-1-i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// First returns the first N items from a slice
|
||||
// Usage: {{ range first .Tables 5 }}...{{ end }}
|
||||
func First(slice interface{}, n int) []interface{} {
|
||||
if n <= 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
length := v.Len()
|
||||
if n > length {
|
||||
n = length
|
||||
}
|
||||
|
||||
result := make([]interface{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Last returns the last N items from a slice
|
||||
// Usage: {{ range last .Tables 5 }}...{{ end }}
|
||||
func Last(slice interface{}, n int) []interface{} {
|
||||
if n <= 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
length := v.Len()
|
||||
if n > length {
|
||||
n = length
|
||||
}
|
||||
|
||||
result := make([]interface{}, n)
|
||||
start := length - n
|
||||
for i := 0; i < n; i++ {
|
||||
result[i] = v.Index(start + i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Skip skips the first N items and returns the rest
|
||||
// Usage: {{ range skip .Tables 2 }}...{{ end }}
|
||||
func Skip(slice interface{}, n int) []interface{} {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
length := v.Len()
|
||||
if n >= length {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
result := make([]interface{}, length-n)
|
||||
for i := n; i < length; i++ {
|
||||
result[i-n] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Take returns the first N items (alias for First)
|
||||
// Usage: {{ range take .Tables 5 }}...{{ end }}
|
||||
func Take(slice interface{}, n int) []interface{} {
|
||||
return First(slice, n)
|
||||
}
|
||||
|
||||
// Concat concatenates multiple slices
|
||||
// Usage: {{ $all := concat .Schema1.Tables .Schema2.Tables }}
|
||||
func Concat(slices ...interface{}) []interface{} {
|
||||
result := make([]interface{}, 0)
|
||||
|
||||
for _, slice := range slices {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result = append(result, v.Index(i).Interface())
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Unique removes duplicates from a slice (compares by string representation)
|
||||
// Usage: {{ $unique := unique .Items }}
|
||||
func Unique(slice interface{}) []interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
seen := make(map[interface{}]bool)
|
||||
result := make([]interface{}, 0)
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
item := v.Index(i).Interface()
|
||||
if !seen[item] {
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SortBy sorts a slice by a field name (for structs) or key (for maps)
|
||||
// Usage: {{ $sorted := sortBy .Tables "Name" }}
|
||||
// Note: This is a basic implementation that works for simple cases
|
||||
func SortBy(slice interface{}, field string) []interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// Convert to interface slice
|
||||
result := make([]interface{}, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
|
||||
// Sort by field
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
vi := getFieldValue(result[i], field)
|
||||
vj := getFieldValue(result[j], field)
|
||||
return compareValues(vi, vj) < 0
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupBy groups a slice by a field value
|
||||
// Usage: {{ $grouped := groupBy .Tables "Schema" }}
|
||||
func GroupBy(slice interface{}, field string) map[interface{}][]interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return map[interface{}][]interface{}{}
|
||||
}
|
||||
|
||||
result := make(map[interface{}][]interface{})
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
item := v.Index(i).Interface()
|
||||
key := getFieldValue(item, field)
|
||||
result[key] = append(result[key], item)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CountIf counts items matching a condition
|
||||
// Note: Since templates can't pass functions, this is limited
|
||||
// Usage in code (not directly in templates)
|
||||
func CountIf(slice interface{}, matches func(interface{}) bool) int {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if matches(v.Index(i).Interface()) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// getFieldValue extracts a field value from a struct, map, or pointer
|
||||
func getFieldValue(item interface{}, field string) interface{} {
|
||||
return reflectutil.GetFieldValue(item, field)
|
||||
}
|
||||
|
||||
// compareValues compares two values for sorting
|
||||
func compareValues(a, b interface{}) int {
|
||||
return reflectutil.CompareValues(a, b)
|
||||
}
|
||||
288
pkg/writers/template/safe_access.go
Normal file
288
pkg/writers/template/safe_access.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/reflectutil"
|
||||
)
|
||||
|
||||
// Get safely gets a value from a map by key
|
||||
// Usage: {{ get .Metadata "key" }}
|
||||
func Get(m interface{}, key interface{}) interface{} {
|
||||
return reflectutil.MapGet(m, key)
|
||||
}
|
||||
|
||||
// GetOr safely gets a value from a map with a default fallback
|
||||
// Usage: {{ getOr .Metadata "key" "default" }}
|
||||
func GetOr(m interface{}, key interface{}, defaultValue interface{}) interface{} {
|
||||
result := Get(m, key)
|
||||
if result == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPath safely gets a nested value using dot notation
|
||||
// Usage: {{ getPath .Config "database.connection.host" }}
|
||||
func GetPath(m interface{}, path string) interface{} {
|
||||
return reflectutil.GetNestedValue(m, path)
|
||||
}
|
||||
|
||||
// GetPathOr safely gets a nested value with a default fallback
|
||||
// Usage: {{ getPathOr .Config "database.connection.host" "localhost" }}
|
||||
func GetPathOr(m interface{}, path string, defaultValue interface{}) interface{} {
|
||||
result := GetPath(m, path)
|
||||
if result == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SafeIndex safely gets an element from a slice by index
|
||||
// Usage: {{ safeIndex .Tables 0 }}
|
||||
func SafeIndex(slice interface{}, index int) interface{} {
|
||||
return reflectutil.SliceIndex(slice, index)
|
||||
}
|
||||
|
||||
// SafeIndexOr safely gets an element from a slice with a default fallback
|
||||
// Usage: {{ safeIndexOr .Tables 0 "default" }}
|
||||
func SafeIndexOr(slice interface{}, index int, defaultValue interface{}) interface{} {
|
||||
result := SafeIndex(slice, index)
|
||||
if result == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Has checks if a key exists in a map
|
||||
// Usage: {{ if has .Metadata "key" }}...{{ end }}
|
||||
func Has(m interface{}, key interface{}) bool {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return false
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return false
|
||||
}
|
||||
|
||||
keyVal := reflect.ValueOf(key)
|
||||
return v.MapIndex(keyVal).IsValid()
|
||||
}
|
||||
|
||||
// HasPath checks if a nested path exists
|
||||
// Usage: {{ if hasPath .Config "database.connection.host" }}...{{ end }}
|
||||
func HasPath(m interface{}, path string) bool {
|
||||
return GetPath(m, path) != nil
|
||||
}
|
||||
|
||||
// Keys returns all keys from a map
|
||||
// Usage: {{ range keys .Metadata }}...{{ end }}
|
||||
func Keys(m interface{}) []interface{} {
|
||||
return reflectutil.MapKeys(m)
|
||||
}
|
||||
|
||||
// Values returns all values from a map
|
||||
// Usage: {{ range values .Table.Columns }}...{{ end }}
|
||||
func Values(m interface{}) []interface{} {
|
||||
return reflectutil.MapValues(m)
|
||||
}
|
||||
|
||||
// Merge merges multiple maps into a new map
|
||||
// Usage: {{ $merged := merge .Map1 .Map2 }}
|
||||
func Merge(maps ...interface{}) map[interface{}]interface{} {
|
||||
result := make(map[interface{}]interface{})
|
||||
|
||||
for _, m := range maps {
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
continue
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
continue
|
||||
}
|
||||
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
result[iter.Key().Interface()] = iter.Value().Interface()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Pick returns a new map with only the specified keys
|
||||
// Usage: {{ $subset := pick .Metadata "name" "description" }}
|
||||
func Pick(m interface{}, keys ...interface{}) map[interface{}]interface{} {
|
||||
result := make(map[interface{}]interface{})
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return result
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
keyVal := reflect.ValueOf(key)
|
||||
mapVal := v.MapIndex(keyVal)
|
||||
if mapVal.IsValid() {
|
||||
result[key] = mapVal.Interface()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Omit returns a new map without the specified keys
|
||||
// Usage: {{ $filtered := omit .Metadata "internal" "private" }}
|
||||
func Omit(m interface{}, keys ...interface{}) map[interface{}]interface{} {
|
||||
result := make(map[interface{}]interface{})
|
||||
v := reflect.ValueOf(m)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return result
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Map {
|
||||
return result
|
||||
}
|
||||
|
||||
// Create a set of keys to omit
|
||||
omitSet := make(map[interface{}]bool)
|
||||
for _, key := range keys {
|
||||
omitSet[key] = true
|
||||
}
|
||||
|
||||
// Add all keys that are not in the omit set
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
key := iter.Key().Interface()
|
||||
if !omitSet[key] {
|
||||
result[key] = iter.Value().Interface()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SliceContains checks if a slice contains a value
|
||||
// Usage: {{ if sliceContains .Names "admin" }}...{{ end }}
|
||||
func SliceContains(slice interface{}, value interface{}) bool {
|
||||
v := reflect.ValueOf(slice)
|
||||
v, ok := reflectutil.Deref(v)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if reflectutil.DeepEqual(v.Index(i).Interface(), value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IndexOf returns the index of a value in a slice, or -1 if not found
|
||||
// Usage: {{ $idx := indexOf .Names "admin" }}
|
||||
func IndexOf(slice interface{}, value interface{}) int {
|
||||
v := reflect.ValueOf(slice)
|
||||
v, ok := reflectutil.Deref(v)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if reflectutil.DeepEqual(v.Index(i).Interface(), value) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// Pluck extracts a field from each element in a slice
|
||||
// Usage: {{ $names := pluck .Tables "Name" }}
|
||||
func Pluck(slice interface{}, field string) []interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
|
||||
// Dereference pointers
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return []interface{}{}
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
result := make([]interface{}, 0, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
item := v.Index(i)
|
||||
|
||||
// Dereference item pointers
|
||||
for item.Kind() == reflect.Ptr {
|
||||
if item.IsNil() {
|
||||
result = append(result, nil)
|
||||
continue
|
||||
}
|
||||
item = item.Elem()
|
||||
}
|
||||
|
||||
switch item.Kind() {
|
||||
case reflect.Struct:
|
||||
fieldVal := item.FieldByName(field)
|
||||
if fieldVal.IsValid() {
|
||||
result = append(result, fieldVal.Interface())
|
||||
} else {
|
||||
result = append(result, nil)
|
||||
}
|
||||
|
||||
case reflect.Map:
|
||||
keyVal := reflect.ValueOf(field)
|
||||
mapVal := item.MapIndex(keyVal)
|
||||
if mapVal.IsValid() {
|
||||
result = append(result, mapVal.Interface())
|
||||
} else {
|
||||
result = append(result, nil)
|
||||
}
|
||||
|
||||
default:
|
||||
result = append(result, nil)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
316
pkg/writers/template/string_helpers.go
Normal file
316
pkg/writers/template/string_helpers.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// ToUpper converts a string to uppercase
|
||||
func ToUpper(s string) string {
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// ToLower converts a string to lowercase
|
||||
func ToLower(s string) string {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// ToCamelCase converts snake_case to camelCase
|
||||
// Examples: user_name → userName, http_request → httpRequest
|
||||
func ToCamelCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "_")
|
||||
for i, part := range parts {
|
||||
if i == 0 {
|
||||
parts[i] = strings.ToLower(part)
|
||||
} else {
|
||||
parts[i] = capitalize(part)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// ToPascalCase converts snake_case to PascalCase
|
||||
// Examples: user_name → UserName, http_request → HTTPRequest
|
||||
func ToPascalCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "_")
|
||||
for i, part := range parts {
|
||||
parts[i] = capitalize(part)
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// ToSnakeCase converts PascalCase/camelCase to snake_case
|
||||
// Examples: UserName → user_name, HTTPRequest → http_request
|
||||
func ToSnakeCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
var prevUpper bool
|
||||
var nextUpper bool
|
||||
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
isUpper := unicode.IsUpper(r)
|
||||
|
||||
if i+1 < len(runes) {
|
||||
nextUpper = unicode.IsUpper(runes[i+1])
|
||||
} else {
|
||||
nextUpper = false
|
||||
}
|
||||
|
||||
if i > 0 && isUpper {
|
||||
// Add underscore before uppercase letter if:
|
||||
// 1. Previous char was lowercase, OR
|
||||
// 2. Next char is lowercase (end of acronym)
|
||||
if !prevUpper || (!nextUpper && i+1 < len(runes)) {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
}
|
||||
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
prevUpper = isUpper
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ToKebabCase converts snake_case or PascalCase/camelCase to kebab-case
|
||||
// Examples: user_name → user-name, UserName → user-name
|
||||
func ToKebabCase(s string) string {
|
||||
// First convert to snake_case, then replace underscores with hyphens
|
||||
snakeCase := ToSnakeCase(s)
|
||||
return strings.ReplaceAll(snakeCase, "_", "-")
|
||||
}
|
||||
|
||||
// Title capitalizes the first letter of each word
|
||||
func Title(s string) string {
|
||||
caser := cases.Title(language.English)
|
||||
src := []byte(s)
|
||||
dest := []byte(s)
|
||||
_, _, _ = caser.Transform(dest, src, true)
|
||||
return string(dest)
|
||||
}
|
||||
|
||||
// Pluralize converts a singular word to plural
|
||||
// Basic implementation with common English rules
|
||||
func Pluralize(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Special cases
|
||||
irregular := map[string]string{
|
||||
"person": "people",
|
||||
"child": "children",
|
||||
"tooth": "teeth",
|
||||
"foot": "feet",
|
||||
"man": "men",
|
||||
"woman": "women",
|
||||
"mouse": "mice",
|
||||
"goose": "geese",
|
||||
"ox": "oxen",
|
||||
"datum": "data",
|
||||
"medium": "media",
|
||||
"analysis": "analyses",
|
||||
"crisis": "crises",
|
||||
"status": "statuses",
|
||||
}
|
||||
|
||||
if plural, ok := irregular[strings.ToLower(s)]; ok {
|
||||
return plural
|
||||
}
|
||||
|
||||
// Already plural (ends in 's' but not 'ss' or 'us')
|
||||
if strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss") && !strings.HasSuffix(s, "us") {
|
||||
return s
|
||||
}
|
||||
|
||||
// Words ending in s, x, z, ch, sh
|
||||
if strings.HasSuffix(s, "s") || strings.HasSuffix(s, "x") ||
|
||||
strings.HasSuffix(s, "z") || strings.HasSuffix(s, "ch") ||
|
||||
strings.HasSuffix(s, "sh") {
|
||||
return s + "es"
|
||||
}
|
||||
|
||||
// Words ending in consonant + y
|
||||
if len(s) >= 2 && strings.HasSuffix(s, "y") {
|
||||
prevChar := s[len(s)-2]
|
||||
if !isVowel(prevChar) {
|
||||
return s[:len(s)-1] + "ies"
|
||||
}
|
||||
}
|
||||
|
||||
// Words ending in f or fe
|
||||
if strings.HasSuffix(s, "f") {
|
||||
return s[:len(s)-1] + "ves"
|
||||
}
|
||||
if strings.HasSuffix(s, "fe") {
|
||||
return s[:len(s)-2] + "ves"
|
||||
}
|
||||
|
||||
// Words ending in consonant + o
|
||||
if len(s) >= 2 && strings.HasSuffix(s, "o") {
|
||||
prevChar := s[len(s)-2]
|
||||
if !isVowel(prevChar) {
|
||||
return s + "es"
|
||||
}
|
||||
}
|
||||
|
||||
// Default: add 's'
|
||||
return s + "s"
|
||||
}
|
||||
|
||||
// Singularize converts a plural word to singular
|
||||
// Basic implementation with common English rules
|
||||
func Singularize(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Special cases
|
||||
irregular := map[string]string{
|
||||
"people": "person",
|
||||
"children": "child",
|
||||
"teeth": "tooth",
|
||||
"feet": "foot",
|
||||
"men": "man",
|
||||
"women": "woman",
|
||||
"mice": "mouse",
|
||||
"geese": "goose",
|
||||
"oxen": "ox",
|
||||
"data": "datum",
|
||||
"media": "medium",
|
||||
"analyses": "analysis",
|
||||
"crises": "crisis",
|
||||
"statuses": "status",
|
||||
}
|
||||
|
||||
if singular, ok := irregular[strings.ToLower(s)]; ok {
|
||||
return singular
|
||||
}
|
||||
|
||||
// Words ending in ies
|
||||
if strings.HasSuffix(s, "ies") && len(s) > 3 {
|
||||
return s[:len(s)-3] + "y"
|
||||
}
|
||||
|
||||
// Words ending in ves
|
||||
if strings.HasSuffix(s, "ves") {
|
||||
return s[:len(s)-3] + "f"
|
||||
}
|
||||
|
||||
// Words ending in ses, xes, zes, ches, shes
|
||||
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") ||
|
||||
strings.HasSuffix(s, "zes") || strings.HasSuffix(s, "ches") ||
|
||||
strings.HasSuffix(s, "shes") {
|
||||
return s[:len(s)-2]
|
||||
}
|
||||
|
||||
// Words ending in s (not ss)
|
||||
if strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss") {
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
// Already singular
|
||||
return s
|
||||
}
|
||||
|
||||
// Trim trims whitespace from both ends
|
||||
func Trim(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// TrimPrefix removes the prefix from the string if present
|
||||
func TrimPrefix(s, prefix string) string {
|
||||
return strings.TrimPrefix(s, prefix)
|
||||
}
|
||||
|
||||
// TrimSuffix removes the suffix from the string if present
|
||||
func TrimSuffix(s, suffix string) string {
|
||||
return strings.TrimSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// Replace replaces occurrences of old with new (n times, or all if n < 0)
|
||||
func Replace(s, old, newstr string, n int) string {
|
||||
return strings.Replace(s, old, newstr, n)
|
||||
}
|
||||
|
||||
// StringContains checks if substr is within s
|
||||
func StringContains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
||||
// HasPrefix checks if string starts with prefix
|
||||
func HasPrefix(s, prefix string) bool {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
}
|
||||
|
||||
// HasSuffix checks if string ends with suffix
|
||||
func HasSuffix(s, suffix string) bool {
|
||||
return strings.HasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// Split splits string by separator
|
||||
func Split(s, sep string) []string {
|
||||
return strings.Split(s, sep)
|
||||
}
|
||||
|
||||
// Join joins string slice with separator
|
||||
func Join(parts []string, sep string) string {
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// capitalize capitalizes the first letter and handles common acronyms
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(s)
|
||||
|
||||
// Handle common acronyms
|
||||
acronyms := map[string]bool{
|
||||
"ID": true,
|
||||
"UUID": true,
|
||||
"GUID": true,
|
||||
"URL": true,
|
||||
"URI": true,
|
||||
"HTTP": true,
|
||||
"HTTPS": true,
|
||||
"API": true,
|
||||
"JSON": true,
|
||||
"XML": true,
|
||||
"SQL": true,
|
||||
"HTML": true,
|
||||
"CSS": true,
|
||||
"RID": true,
|
||||
}
|
||||
|
||||
if acronyms[upper] {
|
||||
return upper
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
runes := []rune(s)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// isVowel checks if a byte is a vowel
|
||||
func isVowel(c byte) bool {
|
||||
c = byte(unicode.ToLower(rune(c)))
|
||||
return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'
|
||||
}
|
||||
108
pkg/writers/template/template_data.go
Normal file
108
pkg/writers/template/template_data.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package template
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// TemplateData wraps the model data with additional context for template execution
|
||||
type TemplateData struct {
|
||||
// One of these will be populated based on execution mode
|
||||
Database *models.Database
|
||||
Schema *models.Schema
|
||||
Domain *models.Domain
|
||||
Script *models.Script
|
||||
Table *models.Table
|
||||
|
||||
// Context information (parent references)
|
||||
ParentSchema *models.Schema // Set for table/script modes
|
||||
ParentDatabase *models.Database // Always set for full database context
|
||||
|
||||
// Pre-computed views for convenience
|
||||
FlatColumns []*models.FlatColumn
|
||||
FlatTables []*models.FlatTable
|
||||
FlatConstraints []*models.FlatConstraint
|
||||
FlatRelationships []*models.FlatRelationship
|
||||
Summary *models.DatabaseSummary
|
||||
|
||||
// User metadata from WriterOptions
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// NewDatabaseData creates template data for database mode
|
||||
func NewDatabaseData(db *models.Database, metadata map[string]interface{}) *TemplateData {
|
||||
return &TemplateData{
|
||||
Database: db,
|
||||
ParentDatabase: db,
|
||||
FlatColumns: db.ToFlatColumns(),
|
||||
FlatTables: db.ToFlatTables(),
|
||||
FlatConstraints: db.ToFlatConstraints(),
|
||||
FlatRelationships: db.ToFlatRelationships(),
|
||||
Summary: db.ToSummary(),
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSchemaData creates template data for schema mode
|
||||
func NewSchemaData(schema *models.Schema, metadata map[string]interface{}) *TemplateData {
|
||||
// Create a temporary database with just this schema for context
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
return &TemplateData{
|
||||
Schema: schema,
|
||||
ParentDatabase: db,
|
||||
FlatColumns: db.ToFlatColumns(),
|
||||
FlatTables: db.ToFlatTables(),
|
||||
FlatConstraints: db.ToFlatConstraints(),
|
||||
FlatRelationships: db.ToFlatRelationships(),
|
||||
Summary: db.ToSummary(),
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDomainData creates template data for domain mode
|
||||
func NewDomainData(domain *models.Domain, db *models.Database, metadata map[string]interface{}) *TemplateData {
|
||||
return &TemplateData{
|
||||
Domain: domain,
|
||||
ParentDatabase: db,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScriptData creates template data for script mode
|
||||
func NewScriptData(script *models.Script, schema *models.Schema, db *models.Database, metadata map[string]interface{}) *TemplateData {
|
||||
return &TemplateData{
|
||||
Script: script,
|
||||
ParentSchema: schema,
|
||||
ParentDatabase: db,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTableData creates template data for table mode
|
||||
func NewTableData(table *models.Table, schema *models.Schema, db *models.Database, metadata map[string]interface{}) *TemplateData {
|
||||
return &TemplateData{
|
||||
Table: table,
|
||||
ParentSchema: schema,
|
||||
ParentDatabase: db,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the primary name for the current template data (used for filename generation)
|
||||
func (td *TemplateData) Name() string {
|
||||
if td.Database != nil {
|
||||
return td.Database.Name
|
||||
}
|
||||
if td.Schema != nil {
|
||||
return td.Schema.Name
|
||||
}
|
||||
if td.Domain != nil {
|
||||
return td.Domain.Name
|
||||
}
|
||||
if td.Script != nil {
|
||||
return td.Script.Name
|
||||
}
|
||||
if td.Table != nil {
|
||||
return td.Table.Name
|
||||
}
|
||||
return "output"
|
||||
}
|
||||
38
pkg/writers/template/type_mappers.go
Normal file
38
pkg/writers/template/type_mappers.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package template
|
||||
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/commontypes"
|
||||
|
||||
// SQLToGo converts SQL types to Go types
|
||||
func SQLToGo(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToGo(sqlType, nullable)
|
||||
}
|
||||
|
||||
// SQLToTypeScript converts SQL types to TypeScript types
|
||||
func SQLToTypeScript(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToTypeScript(sqlType, nullable)
|
||||
}
|
||||
|
||||
// SQLToJava converts SQL types to Java types
|
||||
func SQLToJava(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToJava(sqlType, nullable)
|
||||
}
|
||||
|
||||
// SQLToPython converts SQL types to Python types
|
||||
func SQLToPython(sqlType string) string {
|
||||
return commontypes.SQLToPython(sqlType)
|
||||
}
|
||||
|
||||
// SQLToRust converts SQL types to Rust types
|
||||
func SQLToRust(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToRust(sqlType, nullable)
|
||||
}
|
||||
|
||||
// SQLToCSharp converts SQL types to C# types
|
||||
func SQLToCSharp(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToCSharp(sqlType, nullable)
|
||||
}
|
||||
|
||||
// SQLToPhp converts SQL types to PHP types
|
||||
func SQLToPhp(sqlType string, nullable bool) string {
|
||||
return commontypes.SQLToPhp(sqlType, nullable)
|
||||
}
|
||||
309
pkg/writers/template/writer.go
Normal file
309
pkg/writers/template/writer.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// EntrypointMode defines how the template is executed
|
||||
type EntrypointMode string
|
||||
|
||||
const (
|
||||
// DatabaseMode executes the template once for the entire database (single output)
|
||||
DatabaseMode EntrypointMode = "database"
|
||||
// SchemaMode executes the template once per schema (multi-file output)
|
||||
SchemaMode EntrypointMode = "schema"
|
||||
// DomainMode executes the template once per domain (multi-file output)
|
||||
DomainMode EntrypointMode = "domain"
|
||||
// ScriptMode executes the template once per script (multi-file output)
|
||||
ScriptMode EntrypointMode = "script"
|
||||
// TableMode executes the template once per table (multi-file output)
|
||||
TableMode EntrypointMode = "table"
|
||||
)
|
||||
|
||||
// Writer implements the writers.Writer interface for template-based output
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
templatePath string
|
||||
funcMap template.FuncMap
|
||||
tmpl *template.Template
|
||||
mode EntrypointMode
|
||||
filenamePattern string
|
||||
}
|
||||
|
||||
// NewWriter creates a new template writer with the given options
|
||||
func NewWriter(options *writers.WriterOptions) (*Writer, error) {
|
||||
w := &Writer{
|
||||
options: options,
|
||||
funcMap: BuildFuncMap(),
|
||||
mode: DatabaseMode, // default mode
|
||||
filenamePattern: "{{.Name}}.txt",
|
||||
}
|
||||
|
||||
// Extract template path from metadata
|
||||
if options.Metadata != nil {
|
||||
if path, ok := options.Metadata["template_path"].(string); ok {
|
||||
w.templatePath = path
|
||||
}
|
||||
|
||||
if mode, ok := options.Metadata["mode"].(string); ok {
|
||||
w.mode = EntrypointMode(mode)
|
||||
}
|
||||
|
||||
if pattern, ok := options.Metadata["filename_pattern"].(string); ok {
|
||||
w.filenamePattern = pattern
|
||||
}
|
||||
}
|
||||
|
||||
// Validate template path
|
||||
if w.templatePath == "" {
|
||||
return nil, NewTemplateLoadError("template path is required", nil)
|
||||
}
|
||||
|
||||
// Load and parse template
|
||||
if err := w.loadTemplate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// WriteDatabase writes a database using the template
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
switch w.mode {
|
||||
case DatabaseMode:
|
||||
return w.executeDatabaseMode(db)
|
||||
case SchemaMode:
|
||||
return w.executeSchemaMode(db)
|
||||
case DomainMode:
|
||||
return w.executeDomainMode(db)
|
||||
case ScriptMode:
|
||||
return w.executeScriptMode(db)
|
||||
case TableMode:
|
||||
return w.executeTableMode(db)
|
||||
default:
|
||||
return fmt.Errorf("unknown entrypoint mode: %s", w.mode)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteSchema writes a schema using the template
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
// Create a temporary database with just this schema
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
// WriteTable writes a single table using the template
|
||||
func (w *Writer) WriteTable(table *models.Table) error {
|
||||
// Create a temporary schema and database
|
||||
schema := models.InitSchema(table.Schema)
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
// executeDatabaseMode executes the template once for the entire database
|
||||
func (w *Writer) executeDatabaseMode(db *models.Database) error {
|
||||
data := NewDatabaseData(db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.writeOutput(output, w.options.OutputPath)
|
||||
}
|
||||
|
||||
// executeSchemaMode executes the template once per schema
|
||||
func (w *Writer) executeSchemaMode(db *models.Database) error {
|
||||
for _, schema := range db.Schemas {
|
||||
data := NewSchemaData(schema, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDomainMode executes the template once per domain
|
||||
func (w *Writer) executeDomainMode(db *models.Database) error {
|
||||
for _, domain := range db.Domains {
|
||||
data := NewDomainData(domain, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeScriptMode executes the template once per script
|
||||
func (w *Writer) executeScriptMode(db *models.Database) error {
|
||||
for _, schema := range db.Schemas {
|
||||
for _, script := range schema.Scripts {
|
||||
data := NewScriptData(script, schema, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for script %s: %w", script.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for script %s: %w", script.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for script %s: %w", script.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeTableMode executes the template once per table
|
||||
func (w *Writer) executeTableMode(db *models.Database) error {
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
data := NewTableData(table, schema, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTemplate loads and parses the template file
|
||||
func (w *Writer) loadTemplate() error {
|
||||
// Read template file
|
||||
content, err := os.ReadFile(w.templatePath)
|
||||
if err != nil {
|
||||
return NewTemplateLoadError(fmt.Sprintf("failed to read template file: %s", w.templatePath), err)
|
||||
}
|
||||
|
||||
// Parse template with function map
|
||||
tmpl, err := template.New(filepath.Base(w.templatePath)).Funcs(w.funcMap).Parse(string(content))
|
||||
if err != nil {
|
||||
return NewTemplateParseError(fmt.Sprintf("failed to parse template: %s", w.templatePath), err)
|
||||
}
|
||||
|
||||
w.tmpl = tmpl
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeTemplate executes the template with the given data
|
||||
func (w *Writer) executeTemplate(data *TemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := w.tmpl.Execute(&buf, data); err != nil {
|
||||
return "", NewTemplateExecuteError("failed to execute template", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// generateFilename generates a filename from the filename pattern
|
||||
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
|
||||
// Parse filename pattern as a template
|
||||
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid filename pattern: %w", err)
|
||||
}
|
||||
|
||||
// Execute filename template
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("failed to generate filename: %w", err)
|
||||
}
|
||||
|
||||
filename := buf.String()
|
||||
|
||||
// If output path is a directory, join with generated filename
|
||||
if w.options.OutputPath != "" {
|
||||
// Check if output path is a directory
|
||||
info, err := os.Stat(w.options.OutputPath)
|
||||
if err == nil && info.IsDir() {
|
||||
filename = filepath.Join(w.options.OutputPath, filename)
|
||||
} else {
|
||||
// If it doesn't exist, check if it looks like a directory (ends with /)
|
||||
if strings.HasSuffix(w.options.OutputPath, string(filepath.Separator)) {
|
||||
filename = filepath.Join(w.options.OutputPath, filename)
|
||||
} else {
|
||||
// Use output path as base directory
|
||||
dir := filepath.Dir(w.options.OutputPath)
|
||||
if dir != "." {
|
||||
filename = filepath.Join(dir, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// writeOutput writes the output to a file or stdout
|
||||
func (w *Writer) writeOutput(content string, outputPath string) error {
|
||||
// If output path is empty, write to stdout
|
||||
if outputPath == "" {
|
||||
fmt.Print(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(outputPath)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user