Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c602e3db0 | |||
| 64aeac972a | |||
| 97a57f5dc8 | |||
| adfe126758 | |||
| 1d193c84d7 | |||
| 1d627c74b1 | |||
| 7c6a355458 | |||
| c0ef26b660 | |||
| cb38f95b79 | |||
| 196d87bc29 | |||
| beb1100d86 | |||
| 410b1ee743 | |||
| b5d39aeee4 | |||
| 5fb9a8f231 | |||
| 27da24f575 | |||
| 0fb3469dbd | |||
| 9f29bc112e | |||
| b55737ab4c | |||
| 2a271b9859 | |||
| beb5b4fac8 | |||
| e61204cb3c | |||
| d52b9cdc14 | |||
| f98b278d72 | |||
| 666eab7cec | |||
| 35bc9dfb5c | |||
| aad5db5175 | |||
| d9225a7310 | |||
| 79effe6921 | |||
| 289715ba44 | |||
| 8ca2b50f9c |
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: CI
|
||||
|
||||
run-name: "Test on master branch"
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.23', '1.24', '1.25']
|
||||
go-version: ['1.24', '1.25']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -34,8 +34,8 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
@@ -57,11 +57,13 @@ jobs:
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: --config=.golangci.json
|
||||
- name: Install golangci-lint
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
|
||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Run linter
|
||||
run: make lint
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -76,8 +78,20 @@ jobs:
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./cmd/relspec
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build binary
|
||||
run: make build
|
||||
|
||||
- name: Verify binary exists
|
||||
run: |
|
||||
if [ ! -f build/relspec ]; then
|
||||
echo "Error: Binary not found at build/relspec"
|
||||
exit 1
|
||||
fi
|
||||
echo "Build successful: build/relspec"
|
||||
ls -lh build/relspec
|
||||
|
||||
- name: Check mod tidiness
|
||||
run: |
|
||||
|
||||
72
.github/workflows/integration-tests.yml
vendored
Normal file
72
.github/workflows/integration-tests.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Integration Tests
|
||||
run-name: "Integration Tests"
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
integration-tests:
|
||||
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
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Initialize test database
|
||||
env:
|
||||
PGPASSWORD: relspec_test_password
|
||||
run: |
|
||||
# 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..."
|
||||
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,
|
||||
(SELECT COUNT(*) FROM pg_views WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) as views,
|
||||
(SELECT COUNT(*) FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) as sequences;
|
||||
"
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
RELSPEC_TEST_PG_CONN: postgres://relspec:relspec_test_password@postgres:5432/relspec_test
|
||||
run: make test-integration
|
||||
116
.github/workflows/release.yml
vendored
Normal file
116
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Release
|
||||
run-name: "Making Release"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and Release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "Version: ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
- name: Build binaries for multiple platforms
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
# Linux AMD64
|
||||
GOOS=linux GOARCH=amd64 go build -o dist/relspec-linux-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Linux ARM64
|
||||
GOOS=linux GOARCH=arm64 go build -o dist/relspec-linux-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# macOS AMD64
|
||||
GOOS=darwin GOARCH=amd64 go build -o dist/relspec-darwin-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# macOS ARM64 (Apple Silicon)
|
||||
GOOS=darwin GOARCH=arm64 go build -o dist/relspec-darwin-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Windows AMD64
|
||||
GOOS=windows GOARCH=amd64 go build -o dist/relspec-windows-amd64.exe -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Create checksums
|
||||
cd dist
|
||||
sha256sum * > checksums.txt
|
||||
cd ..
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
# Get the previous tag
|
||||
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$previous_tag" ]; then
|
||||
# No previous tag, get all commits
|
||||
commits=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
# Get commits since the previous tag
|
||||
commits=$(git log "${previous_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
|
||||
# Create release notes
|
||||
cat > release_notes.md << EOF
|
||||
# Release ${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
## Changes
|
||||
|
||||
${commits}
|
||||
|
||||
## Installation
|
||||
|
||||
Download the appropriate binary for your platform:
|
||||
|
||||
- **Linux (AMD64)**: \`relspec-linux-amd64\`
|
||||
- **Linux (ARM64)**: \`relspec-linux-arm64\`
|
||||
- **macOS (Intel)**: \`relspec-darwin-amd64\`
|
||||
- **macOS (Apple Silicon)**: \`relspec-darwin-arm64\`
|
||||
- **Windows (AMD64)**: \`relspec-windows-amd64.exe\`
|
||||
|
||||
Make the binary executable (Linux/macOS):
|
||||
\`\`\`bash
|
||||
chmod +x relspec-*
|
||||
\`\`\`
|
||||
|
||||
Verify the download with the provided checksums.
|
||||
EOF
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
dist/relspec-linux-amd64
|
||||
dist/relspec-linux-arm64
|
||||
dist/relspec-darwin-amd64
|
||||
dist/relspec-darwin-arm64
|
||||
dist/relspec-windows-amd64.exe
|
||||
dist/checksums.txt
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "Release ${{ steps.get_version.outputs.VERSION }} created successfully!"
|
||||
echo "Binaries built for:"
|
||||
echo " - Linux (amd64, arm64)"
|
||||
echo " - macOS (amd64, arm64)"
|
||||
echo " - Windows (amd64)"
|
||||
35
AI_USE.md
Normal file
35
AI_USE.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# AI Usage Declaration
|
||||
|
||||
This Go project utilizes AI tools for the following purposes:
|
||||
|
||||
- Generating and improving documentation
|
||||
- Writing and enhancing tests
|
||||
- Refactoring and optimizing existing code
|
||||
|
||||
AI is **not** used for core design or architecture decisions.
|
||||
All design decisions are deferred to human discussion.
|
||||
AI is employed only for enhancements to human-written code.
|
||||
|
||||
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
|
||||
|
||||
|
||||
.-""""""-.
|
||||
.' '.
|
||||
/ O O \
|
||||
: ` :
|
||||
| |
|
||||
: .------. :
|
||||
\ ' ' /
|
||||
'. .'
|
||||
'-......-'
|
||||
MEGAMIND AI
|
||||
[============]
|
||||
|
||||
___________
|
||||
/___________\
|
||||
/_____________\
|
||||
| ASSIMILATE |
|
||||
| RESISTANCE |
|
||||
| IS FUTILE |
|
||||
\_____________/
|
||||
\___________/
|
||||
148
Makefile
148
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build test lint coverage clean install help docker-up docker-down docker-test docker-test-integration
|
||||
.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,17 +14,51 @@ 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
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
|
||||
|
||||
test: ## Run tests
|
||||
@echo "Running tests..."
|
||||
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
||||
test: test-unit ## Run all unit tests (alias for test-unit)
|
||||
|
||||
test-unit: ## Run unit tests (excludes integration tests)
|
||||
@echo "Running unit tests..."
|
||||
$(GOTEST) -v -race -coverprofile=coverage.out -covermode=atomic $$(go list ./... | grep -v '/tests/integration' | grep -v '/tests/assets' | grep -v '/pkg/readers/pgsql')
|
||||
|
||||
test-integration: ## Run integration tests (requires RELSPEC_TEST_PG_CONN environment variable)
|
||||
@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:5439/relspec_test'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Running PostgreSQL reader tests..."
|
||||
$(GOTEST) -v -count=1 ./pkg/readers/pgsql/
|
||||
@echo "Running general integration tests..."
|
||||
$(GOTEST) -v -count=1 ./tests/integration/
|
||||
|
||||
coverage: test ## Run tests with coverage report
|
||||
@echo "Generating coverage report..."
|
||||
@@ -40,6 +74,15 @@ lint: ## Run linter
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
lintfix: ## Run linter
|
||||
@echo "Running linter..."
|
||||
@if command -v golangci-lint > /dev/null; then \
|
||||
golangci-lint run --config=.golangci.json --fix; \
|
||||
else \
|
||||
echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
clean: ## Clean build artifacts
|
||||
@echo "Cleaning..."
|
||||
$(GOCLEAN)
|
||||
@@ -58,36 +101,105 @@ 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
|
||||
|
||||
release: ## Create and push a new release tag (auto-increments patch version)
|
||||
@echo "Creating new release..."
|
||||
@latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
|
||||
if [ -z "$$latest_tag" ]; then \
|
||||
version="v1.0.0"; \
|
||||
echo "No existing tags found. Creating first release: $$version"; \
|
||||
commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \
|
||||
else \
|
||||
echo "Latest tag: $$latest_tag"; \
|
||||
version_number=$${latest_tag#v}; \
|
||||
IFS='.' read -r major minor patch <<< "$$version_number"; \
|
||||
patch=$$((patch + 1)); \
|
||||
version="v$$major.$$minor.$$patch"; \
|
||||
echo "Creating new release: $$version"; \
|
||||
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
|
||||
fi; \
|
||||
if [ -z "$$commit_logs" ]; then \
|
||||
tag_message="Release $$version"; \
|
||||
else \
|
||||
tag_message="Release $$version\n\n$$commit_logs"; \
|
||||
fi; \
|
||||
git tag -a "$$version" -m "$$tag_message"; \
|
||||
git push origin "$$version"; \
|
||||
echo "Tag $$version created and pushed to remote repository."
|
||||
|
||||
release-version: ## Create and push a release with specific version (use: make release-version VERSION=v1.2.3)
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "Error: VERSION is required. Usage: make release-version VERSION=v1.2.3"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@version="$(VERSION)"; \
|
||||
if ! echo "$$version" | grep -q "^v"; then \
|
||||
version="v$$version"; \
|
||||
fi; \
|
||||
echo "Creating release: $$version"; \
|
||||
latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
|
||||
if [ -z "$$latest_tag" ]; then \
|
||||
commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \
|
||||
else \
|
||||
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
|
||||
fi; \
|
||||
if [ -z "$$commit_logs" ]; then \
|
||||
tag_message="Release $$version"; \
|
||||
else \
|
||||
tag_message="Release $$version\n\n$$commit_logs"; \
|
||||
fi; \
|
||||
git tag -a "$$version" -m "$$tag_message"; \
|
||||
git push origin "$$version"; \
|
||||
echo "Tag $$version created and pushed to remote repository."
|
||||
|
||||
help: ## Display this help screen
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
140
README.md
140
README.md
@@ -1,34 +1,89 @@
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Input Formats
|
||||
- **XML** - Generic XML schema definitions
|
||||
- **JSON** - JSON-based schema specifications
|
||||
- **Clarion DCTX** - Clarion database dictionary format
|
||||
- **Database Inspection** - Direct database introspection
|
||||
- **GORM Models** - Read existing GORM Go structs
|
||||
- **Bun Models** - Read existing Bun Go structs
|
||||
### Readers (Input Formats)
|
||||
|
||||
### Output Formats
|
||||
- **GORM Models** - Generate GORM-compatible Go structs
|
||||
- **Bun Models** - Generate Bun-compatible Go structs
|
||||
- **JSON** - Standard JSON schema output
|
||||
- **YAML** - Human-readable YAML format
|
||||
RelSpec can read database schemas from multiple sources:
|
||||
|
||||
#### ORM Models
|
||||
- [GORM](pkg/readers/gorm/README.md) - Go GORM model definitions
|
||||
- [Bun](pkg/readers/bun/README.md) - Go Bun model definitions
|
||||
- [Drizzle](pkg/readers/drizzle/README.md) - TypeScript Drizzle ORM schemas
|
||||
- [Prisma](pkg/readers/prisma/README.md) - Prisma schema language
|
||||
- [TypeORM](pkg/readers/typeorm/README.md) - TypeScript TypeORM entities
|
||||
|
||||
#### Database Inspection
|
||||
- [PostgreSQL](pkg/readers/pgsql/README.md) - Direct PostgreSQL database introspection
|
||||
|
||||
#### Schema Formats
|
||||
- [DBML](pkg/readers/dbml/README.md) - Database Markup Language (dbdiagram.io)
|
||||
- [DCTX](pkg/readers/dctx/README.md) - Clarion database dictionary format
|
||||
- [DrawDB](pkg/readers/drawdb/README.md) - DrawDB JSON format
|
||||
- [GraphQL](pkg/readers/graphql/README.md) - GraphQL Schema Definition Language (SDL)
|
||||
- [JSON](pkg/readers/json/README.md) - RelSpec canonical JSON format
|
||||
- [YAML](pkg/readers/yaml/README.md) - RelSpec canonical YAML format
|
||||
|
||||
### Writers (Output Formats)
|
||||
|
||||
RelSpec can write database schemas to multiple formats:
|
||||
|
||||
#### ORM Models
|
||||
- [GORM](pkg/writers/gorm/README.md) - Generate GORM-compatible Go structs
|
||||
- [Bun](pkg/writers/bun/README.md) - Generate Bun-compatible Go structs
|
||||
- [Drizzle](pkg/writers/drizzle/README.md) - Generate Drizzle ORM TypeScript schemas
|
||||
- [Prisma](pkg/writers/prisma/README.md) - Generate Prisma schema files
|
||||
- [TypeORM](pkg/writers/typeorm/README.md) - Generate TypeORM TypeScript entities
|
||||
|
||||
#### Database DDL
|
||||
- [PostgreSQL](pkg/writers/pgsql/README.md) - PostgreSQL DDL (CREATE TABLE, etc.)
|
||||
|
||||
#### Schema Formats
|
||||
- [DBML](pkg/writers/dbml/README.md) - Database Markup Language
|
||||
- [DCTX](pkg/writers/dctx/README.md) - Clarion database dictionary format
|
||||
- [DrawDB](pkg/writers/drawdb/README.md) - DrawDB JSON format
|
||||
- [GraphQL](pkg/writers/graphql/README.md) - GraphQL Schema Definition Language (SDL)
|
||||
- [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)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -40,30 +95,65 @@ go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
|
||||
## Usage
|
||||
|
||||
### Schema Conversion
|
||||
|
||||
```bash
|
||||
# Inspect database and generate GORM models
|
||||
relspec --input db --conn "postgres://..." --output gorm --out-file models.go
|
||||
# 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
|
||||
```
|
||||
@@ -94,7 +184,7 @@ go test ./...
|
||||
|
||||
Apache License 2.0 - See [LICENSE](LICENSE) for details.
|
||||
|
||||
Copyright 2025 wdevs
|
||||
Copyright 2025 Warky Devs
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
30
TODO.md
30
TODO.md
@@ -2,22 +2,24 @@
|
||||
|
||||
|
||||
## Input Readers / Writers
|
||||
- [x] **Database Inspector**
|
||||
- [x] PostgreSQL driver
|
||||
|
||||
- [✔️] **Database Inspector**
|
||||
- [✔️] PostgreSQL driver
|
||||
- [ ] MySQL driver
|
||||
- [ ] SQLite driver
|
||||
- [ ] MSSQL driver
|
||||
- [x] Foreign key detection
|
||||
- [x] Index extraction
|
||||
- [ ] .sql file generation with sequence and priority
|
||||
- [*] .dbml: Database Markup Language (DBML) for textual schema representation.
|
||||
- [ ] Prisma schema support (PSL format) .prisma
|
||||
- [ ] Entity Framework (.NET) model .edmx
|
||||
- [ ] TypeORM support
|
||||
- [ ] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS)
|
||||
- [ ] .avsc: Avro schema (JSON format for data serialization)
|
||||
|
||||
- [✔️] Foreign key detection
|
||||
- [✔️] Index extraction
|
||||
- [*] .sql file generation with sequence and priority
|
||||
- [✔️] .dbml: Database Markup Language (DBML) for textual schema representation.
|
||||
- [✔️] Prisma schema support (PSL format) .prisma
|
||||
- [✔️] Drizzle ORM support .ts (TypeScript / JavaScript) (Mr. Edd wanted to move from Prisma to Drizzle. If you are bugs, you are welcome to do pull requests or issues)
|
||||
- [☠️] Entity Framework (.NET) model .edmx (Fuck no, EDMX files were bloated, verbose XML nightmares—hard to merge, error-prone, and a pain in teams. Microsoft wisely ditched them in EF Core for code-first. Classic overkill from old MS era.)
|
||||
- [✔️] TypeORM support
|
||||
- [] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP) (💲 Someone can do this, not me)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS) (💲 Someone can do this, not me)
|
||||
- [] .avsc: Avro schema (JSON format for data serialization) (💲 Someone can do this, not me)
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
|
||||
## Documentation
|
||||
@@ -36,7 +38,7 @@
|
||||
- [ ] Web UI for visual editing
|
||||
- [ ] REST API server mode
|
||||
- [ ] Support for NoSQL databases
|
||||
- [ ] GraphQL schema generation
|
||||
|
||||
|
||||
## Performance
|
||||
- [ ] Concurrent processing for multiple tables
|
||||
|
||||
@@ -6,26 +6,35 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,20 +60,28 @@ 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:
|
||||
@@ -123,18 +140,27 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, json, yaml, gorm, bun, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
convertCmd.Flags().StringVar(&convertSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
|
||||
convertCmd.Flags().StringVar(&convertTargetType, "to", "", "Target format (dbml, dctx, drawdb, json, yaml, gorm, bun, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertTargetPath, "to-path", "", "Target output path (file or directory)")
|
||||
convertCmd.Flags().StringVar(&convertPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)")
|
||||
|
||||
convertCmd.MarkFlagRequired("from")
|
||||
convertCmd.MarkFlagRequired("to")
|
||||
convertCmd.MarkFlagRequired("to-path")
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
}
|
||||
err = convertCmd.MarkFlagRequired("to")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking to flag as required: %v\n", err)
|
||||
}
|
||||
err = convertCmd.MarkFlagRequired("to-path")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking to-path flag as required: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runConvert(cmd *cobra.Command, args []string) error {
|
||||
@@ -239,6 +265,30 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
}
|
||||
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 "graphql", "gql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported source format: %s", dbType)
|
||||
}
|
||||
@@ -287,9 +337,21 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
}
|
||||
writer = wbun.NewWriter(writerOpts)
|
||||
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(writerOpts)
|
||||
|
||||
case "pgsql", "postgres", "postgresql", "sql":
|
||||
writer = wpgsql.NewWriter(writerOpts)
|
||||
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(writerOpts)
|
||||
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(writerOpts)
|
||||
|
||||
case "graphql", "gql":
|
||||
writer = wgraphql.NewWriter(writerOpts)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported target format: %s", dbType)
|
||||
}
|
||||
@@ -318,7 +380,7 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
}
|
||||
|
||||
// For formats like DCTX that don't support full database writes, require schema filter
|
||||
if strings.ToLower(dbType) == "dctx" {
|
||||
if strings.EqualFold(dbType, "dctx") {
|
||||
if len(db.Schemas) == 0 {
|
||||
return fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/diff"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
@@ -15,7 +17,6 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -96,8 +97,14 @@ func init() {
|
||||
diffCmd.Flags().StringVar(&outputFormat, "format", "summary", "Output format (summary, json, html)")
|
||||
diffCmd.Flags().StringVar(&outputPath, "output", "", "Output file path (default: stdout for summary, required for json/html)")
|
||||
|
||||
diffCmd.MarkFlagRequired("from")
|
||||
diffCmd.MarkFlagRequired("to")
|
||||
err := diffCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
}
|
||||
err = diffCmd.MarkFlagRequired("to")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking to flag as required: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runDiff(cmd *cobra.Command, args []string) error {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -18,4 +18,7 @@ JSON, YAML, SQL, etc.).`,
|
||||
func init() {
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
552
docs/TEMPLATE_MODE.md
Normal file
552
docs/TEMPLATE_MODE.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# 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 |
|
||||
| `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`, `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
|
||||
```
|
||||
|
||||
### 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
|
||||
- `.Description`, `.Comment` - Documentation
|
||||
|
||||
**Schema:**
|
||||
- `.Name` - Schema name
|
||||
- `.Tables` - List of tables
|
||||
- `.Views`, `.Sequences`, `.Scripts` - Other objects
|
||||
- `.Enums` - Enum types
|
||||
|
||||
**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/)
|
||||
11
go.mod
11
go.mod
@@ -1,12 +1,14 @@
|
||||
module git.warky.dev/wdevs/relspecgo
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
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
|
||||
)
|
||||
|
||||
@@ -15,10 +17,15 @@ require (
|
||||
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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // 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
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
)
|
||||
|
||||
14
go.sum
14
go.sum
@@ -15,6 +15,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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=
|
||||
@@ -22,6 +24,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/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=
|
||||
@@ -36,11 +40,21 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
|
||||
github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
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=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ask if the user wants to make a release version
|
||||
read -p "Do you want to make a release version? (y/n): " make_release
|
||||
|
||||
if [[ $make_release =~ ^[Yy]$ ]]; then
|
||||
# Get the latest tag from git
|
||||
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
# No tags exist yet, start with v1.0.0
|
||||
suggested_version="v1.0.0"
|
||||
echo "No existing tags found. Starting with $suggested_version"
|
||||
else
|
||||
echo "Latest tag: $latest_tag"
|
||||
|
||||
# Remove 'v' prefix if present
|
||||
version_number="${latest_tag#v}"
|
||||
|
||||
# Split version into major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$version_number"
|
||||
|
||||
# Increment patch version
|
||||
patch=$((patch + 1))
|
||||
|
||||
# Construct new version
|
||||
suggested_version="v${major}.${minor}.${patch}"
|
||||
echo "Suggested next version: $suggested_version"
|
||||
fi
|
||||
|
||||
# Ask the user for the version number with the suggested version as default
|
||||
read -p "Enter the version number (press Enter for $suggested_version): " version
|
||||
|
||||
# Use suggested version if user pressed Enter without input
|
||||
if [ -z "$version" ]; then
|
||||
version="$suggested_version"
|
||||
fi
|
||||
|
||||
# Prepend 'v' to the version if it doesn't start with it
|
||||
if ! [[ $version =~ ^v ]]; then
|
||||
version="v$version"
|
||||
fi
|
||||
|
||||
# Get commit logs since the last tag
|
||||
if [ -z "$latest_tag" ]; then
|
||||
# No previous tag, get all commits
|
||||
commit_logs=$(git log --pretty=format:"- %s" --no-merges)
|
||||
else
|
||||
# Get commits since the last tag
|
||||
commit_logs=$(git log "${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges)
|
||||
fi
|
||||
|
||||
# Create the tag message
|
||||
if [ -z "$commit_logs" ]; then
|
||||
tag_message="Release $version"
|
||||
else
|
||||
tag_message="Release $version
|
||||
|
||||
${commit_logs}"
|
||||
fi
|
||||
|
||||
# Create an annotated tag with the commit logs
|
||||
git tag -a "$version" -m "$tag_message"
|
||||
|
||||
# Push the tag to the remote repository
|
||||
git push origin "$version"
|
||||
|
||||
echo "Tag $version created and pushed to the remote repository."
|
||||
else
|
||||
echo "No release version created."
|
||||
fi
|
||||
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"
|
||||
}
|
||||
@@ -2,14 +2,15 @@ package diff
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// CompareDatabases compares two database models and returns the differences
|
||||
func CompareDatabases(source, target *models.Database) *DiffResult {
|
||||
result := &DiffResult{
|
||||
Source: source.Name,
|
||||
Target: target.Name,
|
||||
Source: source.Name,
|
||||
Target: target.Name,
|
||||
Schemas: compareSchemas(source.Schemas, target.Schemas),
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -4,8 +4,8 @@ import "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
|
||||
// DiffResult represents the complete difference analysis between two databases
|
||||
type DiffResult struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Schemas *SchemaDiff `json:"schemas"`
|
||||
}
|
||||
|
||||
@@ -18,17 +18,17 @@ type SchemaDiff struct {
|
||||
|
||||
// SchemaChange represents changes within a schema
|
||||
type SchemaChange struct {
|
||||
Name string `json:"name"`
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
}
|
||||
|
||||
// TableDiff represents differences in tables
|
||||
type TableDiff struct {
|
||||
Missing []*models.Table `json:"missing"` // Tables in source but not in target
|
||||
Extra []*models.Table `json:"extra"` // Tables in target but not in source
|
||||
Modified []*TableChange `json:"modified"` // Tables that exist in both but differ
|
||||
Missing []*models.Table `json:"missing"` // Tables in source but not in target
|
||||
Extra []*models.Table `json:"extra"` // Tables in target but not in source
|
||||
Modified []*TableChange `json:"modified"` // Tables that exist in both but differ
|
||||
}
|
||||
|
||||
// TableChange represents changes within a table
|
||||
@@ -50,16 +50,16 @@ type ColumnDiff struct {
|
||||
|
||||
// ColumnChange represents a modified column
|
||||
type ColumnChange struct {
|
||||
Name string `json:"name"`
|
||||
Source *models.Column `json:"source"`
|
||||
Target *models.Column `json:"target"`
|
||||
Changes map[string]any `json:"changes"` // Map of field name to what changed
|
||||
Name string `json:"name"`
|
||||
Source *models.Column `json:"source"`
|
||||
Target *models.Column `json:"target"`
|
||||
Changes map[string]any `json:"changes"` // Map of field name to what changed
|
||||
}
|
||||
|
||||
// IndexDiff represents differences in indexes
|
||||
type IndexDiff struct {
|
||||
Missing []*models.Index `json:"missing"` // Indexes in source but not in target
|
||||
Extra []*models.Index `json:"extra"` // Indexes in target but not in source
|
||||
Missing []*models.Index `json:"missing"` // Indexes in source but not in target
|
||||
Extra []*models.Index `json:"extra"` // Indexes in target but not in source
|
||||
Modified []*IndexChange `json:"modified"` // Indexes that exist in both but differ
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ type RelationshipChange struct {
|
||||
|
||||
// ViewDiff represents differences in views
|
||||
type ViewDiff struct {
|
||||
Missing []*models.View `json:"missing"` // Views in source but not in target
|
||||
Extra []*models.View `json:"extra"` // Views in target but not in source
|
||||
Missing []*models.View `json:"missing"` // Views in source but not in target
|
||||
Extra []*models.View `json:"extra"` // Views in target but not in source
|
||||
Modified []*ViewChange `json:"modified"` // Views that exist in both but differ
|
||||
}
|
||||
|
||||
@@ -133,14 +133,14 @@ type SequenceChange struct {
|
||||
|
||||
// Summary provides counts for quick overview
|
||||
type Summary struct {
|
||||
Schemas SchemaSummary `json:"schemas"`
|
||||
Tables TableSummary `json:"tables"`
|
||||
Columns ColumnSummary `json:"columns"`
|
||||
Indexes IndexSummary `json:"indexes"`
|
||||
Constraints ConstraintSummary `json:"constraints"`
|
||||
Schemas SchemaSummary `json:"schemas"`
|
||||
Tables TableSummary `json:"tables"`
|
||||
Columns ColumnSummary `json:"columns"`
|
||||
Indexes IndexSummary `json:"indexes"`
|
||||
Constraints ConstraintSummary `json:"constraints"`
|
||||
Relationships RelationshipSummary `json:"relationships"`
|
||||
Views ViewSummary `json:"views"`
|
||||
Sequences SequenceSummary `json:"sequences"`
|
||||
Views ViewSummary `json:"views"`
|
||||
Sequences SequenceSummary `json:"sequences"`
|
||||
}
|
||||
|
||||
type SchemaSummary struct {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,7 +2,13 @@ package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// DCTXDictionary represents the root element of a DCTX file
|
||||
// DCTX File Format Models
|
||||
//
|
||||
// This file defines the data structures for parsing and generating DCTX
|
||||
// (Data Dictionary) XML files, which are used by Clarion development tools
|
||||
// for database schema definitions.
|
||||
|
||||
// DCTXDictionary represents the root element of a DCTX file.
|
||||
type DCTXDictionary struct {
|
||||
XMLName xml.Name `xml:"Dictionary"`
|
||||
Name string `xml:"Name,attr"`
|
||||
@@ -11,7 +17,7 @@ type DCTXDictionary struct {
|
||||
Relations []DCTXRelation `xml:"Relation,omitempty"`
|
||||
}
|
||||
|
||||
// DCTXTable represents a table definition in DCTX
|
||||
// DCTXTable represents a table definition in DCTX format.
|
||||
type DCTXTable struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
Name string `xml:"Name,attr"`
|
||||
@@ -25,7 +31,8 @@ type DCTXTable struct {
|
||||
Options []DCTXOption `xml:"Option,omitempty"`
|
||||
}
|
||||
|
||||
// DCTXField represents a field/column definition in DCTX
|
||||
// DCTXField represents a field/column definition in DCTX format.
|
||||
// Fields can be nested for GROUP structures.
|
||||
type DCTXField struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
Name string `xml:"Name,attr"`
|
||||
@@ -37,7 +44,7 @@ type DCTXField struct {
|
||||
Options []DCTXOption `xml:"Option,omitempty"`
|
||||
}
|
||||
|
||||
// DCTXKey represents an index or key definition in DCTX
|
||||
// DCTXKey represents an index or key definition in DCTX format.
|
||||
type DCTXKey struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
Name string `xml:"Name,attr"`
|
||||
@@ -49,7 +56,7 @@ type DCTXKey struct {
|
||||
Components []DCTXComponent `xml:"Component"`
|
||||
}
|
||||
|
||||
// DCTXComponent represents a component of a key (field reference)
|
||||
// DCTXComponent represents a component of a key, referencing a field in the index.
|
||||
type DCTXComponent struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
FieldId string `xml:"FieldId,attr,omitempty"`
|
||||
@@ -57,14 +64,14 @@ type DCTXComponent struct {
|
||||
Ascend bool `xml:"Ascend,attr,omitempty"`
|
||||
}
|
||||
|
||||
// DCTXOption represents a property option in DCTX
|
||||
// DCTXOption represents a property option in DCTX format for metadata storage.
|
||||
type DCTXOption struct {
|
||||
Property string `xml:"Property,attr"`
|
||||
PropertyType string `xml:"PropertyType,attr,omitempty"`
|
||||
PropertyValue string `xml:"PropertyValue,attr"`
|
||||
}
|
||||
|
||||
// DCTXRelation represents a relationship/foreign key in DCTX
|
||||
// DCTXRelation represents a relationship/foreign key in DCTX format.
|
||||
type DCTXRelation struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
PrimaryTable string `xml:"PrimaryTable,attr"`
|
||||
@@ -77,7 +84,7 @@ type DCTXRelation struct {
|
||||
PrimaryMappings []DCTXFieldMapping `xml:"PrimaryMapping,omitempty"`
|
||||
}
|
||||
|
||||
// DCTXFieldMapping represents a field mapping in a relation
|
||||
// DCTXFieldMapping represents a field mapping in a relation for multi-column foreign keys.
|
||||
type DCTXFieldMapping struct {
|
||||
Guid string `xml:"Guid,attr"`
|
||||
Field string `xml:"Field,attr"`
|
||||
|
||||
@@ -2,11 +2,14 @@ package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
// =============================================================================
|
||||
// Flat/Denormalized Views - Flattened structures with fully qualified names
|
||||
// =============================================================================
|
||||
// Flat/Denormalized Views
|
||||
//
|
||||
// This file provides flattened data structures with fully qualified names
|
||||
// for easier querying and analysis of database schemas without navigating
|
||||
// nested hierarchies.
|
||||
|
||||
// FlatColumn represents a column with full context in a single structure
|
||||
// FlatColumn represents a column with full database context in a single structure.
|
||||
// It includes fully qualified names for easy identification and querying.
|
||||
type FlatColumn struct {
|
||||
DatabaseName string `json:"database_name" yaml:"database_name" xml:"database_name"`
|
||||
SchemaName string `json:"schema_name" yaml:"schema_name" xml:"schema_name"`
|
||||
@@ -25,7 +28,7 @@ type FlatColumn struct {
|
||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ToFlatColumns converts a Database to a slice of FlatColumns
|
||||
// ToFlatColumns converts a Database to a slice of FlatColumns for denormalized access to all columns.
|
||||
func (d *Database) ToFlatColumns() []*FlatColumn {
|
||||
flatColumns := make([]*FlatColumn, 0)
|
||||
|
||||
@@ -56,7 +59,7 @@ func (d *Database) ToFlatColumns() []*FlatColumn {
|
||||
return flatColumns
|
||||
}
|
||||
|
||||
// FlatTable represents a table with full context
|
||||
// FlatTable represents a table with full database context and aggregated counts.
|
||||
type FlatTable struct {
|
||||
DatabaseName string `json:"database_name" yaml:"database_name" xml:"database_name"`
|
||||
SchemaName string `json:"schema_name" yaml:"schema_name" xml:"schema_name"`
|
||||
@@ -70,7 +73,7 @@ type FlatTable struct {
|
||||
IndexCount int `json:"index_count" yaml:"index_count" xml:"index_count"`
|
||||
}
|
||||
|
||||
// ToFlatTables converts a Database to a slice of FlatTables
|
||||
// ToFlatTables converts a Database to a slice of FlatTables for denormalized access to all tables.
|
||||
func (d *Database) ToFlatTables() []*FlatTable {
|
||||
flatTables := make([]*FlatTable, 0)
|
||||
|
||||
@@ -94,7 +97,7 @@ func (d *Database) ToFlatTables() []*FlatTable {
|
||||
return flatTables
|
||||
}
|
||||
|
||||
// FlatConstraint represents a constraint with full context
|
||||
// FlatConstraint represents a constraint with full database context and resolved references.
|
||||
type FlatConstraint struct {
|
||||
DatabaseName string `json:"database_name" yaml:"database_name" xml:"database_name"`
|
||||
SchemaName string `json:"schema_name" yaml:"schema_name" xml:"schema_name"`
|
||||
@@ -112,7 +115,7 @@ type FlatConstraint struct {
|
||||
OnUpdate string `json:"on_update,omitempty" yaml:"on_update,omitempty" xml:"on_update,omitempty"`
|
||||
}
|
||||
|
||||
// ToFlatConstraints converts a Database to a slice of FlatConstraints
|
||||
// ToFlatConstraints converts a Database to a slice of FlatConstraints for denormalized access to all constraints.
|
||||
func (d *Database) ToFlatConstraints() []*FlatConstraint {
|
||||
flatConstraints := make([]*FlatConstraint, 0)
|
||||
|
||||
@@ -148,7 +151,7 @@ func (d *Database) ToFlatConstraints() []*FlatConstraint {
|
||||
return flatConstraints
|
||||
}
|
||||
|
||||
// FlatRelationship represents a relationship with full context
|
||||
// FlatRelationship represents a relationship with full database context and fully qualified table names.
|
||||
type FlatRelationship struct {
|
||||
DatabaseName string `json:"database_name" yaml:"database_name" xml:"database_name"`
|
||||
RelationshipName string `json:"relationship_name" yaml:"relationship_name" xml:"relationship_name"`
|
||||
@@ -164,7 +167,7 @@ type FlatRelationship struct {
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ToFlatRelationships converts a Database to a slice of FlatRelationships
|
||||
// ToFlatRelationships converts a Database to a slice of FlatRelationships for denormalized access to all relationships.
|
||||
func (d *Database) ToFlatRelationships() []*FlatRelationship {
|
||||
flatRelationships := make([]*FlatRelationship, 0)
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// Package models provides the core data structures for representing database schemas.
|
||||
// It defines types for databases, schemas, tables, columns, relationships, constraints,
|
||||
// indexes, views, sequences, and other database objects. These models serve as the
|
||||
// intermediate representation for converting between various database schema formats.
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// DatabaseType represents the type of database system.
|
||||
type DatabaseType string
|
||||
|
||||
// Supported database types.
|
||||
const (
|
||||
PostgresqlDatabaseType DatabaseType = "pgsql"
|
||||
MSSQLDatabaseType DatabaseType = "mssql"
|
||||
SqlLiteDatabaseType DatabaseType = "sqlite"
|
||||
PostgresqlDatabaseType DatabaseType = "pgsql" // PostgreSQL database
|
||||
MSSQLDatabaseType DatabaseType = "mssql" // Microsoft SQL Server database
|
||||
SqlLiteDatabaseType DatabaseType = "sqlite" // SQLite database
|
||||
)
|
||||
|
||||
// Database represents the complete database schema
|
||||
@@ -21,11 +27,13 @@ type Database struct {
|
||||
SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty" xml:"source_format,omitempty"` // Source Format of the database.
|
||||
}
|
||||
|
||||
// SQLNamer returns the database name in lowercase
|
||||
// SQLName returns the database name in lowercase for SQL compatibility.
|
||||
func (d *Database) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
@@ -40,13 +48,16 @@ type Schema struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// SQLName returns the schema name in lowercase
|
||||
// SQLName returns the schema name in lowercase for SQL compatibility.
|
||||
func (d *Schema) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// Table represents a database table with its columns, constraints, indexes,
|
||||
// and relationships. Tables are the primary data storage structures in a database.
|
||||
type Table struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
@@ -62,11 +73,12 @@ type Table struct {
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
}
|
||||
|
||||
// SQLName returns the table name in lowercase
|
||||
// SQLName returns the table name in lowercase for SQL compatibility.
|
||||
func (d *Table) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// GetPrimaryKey returns the primary key column for the table, or nil if none exists.
|
||||
func (m Table) GetPrimaryKey() *Column {
|
||||
for _, column := range m.Columns {
|
||||
if column.IsPrimaryKey {
|
||||
@@ -76,6 +88,7 @@ func (m Table) GetPrimaryKey() *Column {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForeignKeys returns all foreign key constraints for the table.
|
||||
func (m Table) GetForeignKeys() []*Constraint {
|
||||
keys := make([]*Constraint, 0)
|
||||
|
||||
@@ -100,7 +113,7 @@ type View struct {
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
}
|
||||
|
||||
// SQLName returns the view name in lowercase
|
||||
// SQLName returns the view name in lowercase for SQL compatibility.
|
||||
func (d *View) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
@@ -123,7 +136,7 @@ type Sequence struct {
|
||||
RefSchema *Schema `json:"-" yaml:"-" xml:"-"` // Excluded to prevent circular references
|
||||
}
|
||||
|
||||
// SQLName returns the sequence name in lowercase
|
||||
// SQLName returns the sequence name in lowercase for SQL compatibility.
|
||||
func (d *Sequence) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
@@ -147,11 +160,13 @@ type Column struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the table name in lowercase
|
||||
// SQLName returns the column name in lowercase for SQL compatibility.
|
||||
func (d *Column) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// Index represents a database index for optimizing query performance.
|
||||
// Indexes can be unique, partial, or include additional columns.
|
||||
type Index struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
@@ -167,19 +182,23 @@ type Index struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the Indexin lowercase
|
||||
// SQLName returns the index name in lowercase for SQL compatibility.
|
||||
func (d *Index) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// RelationType represents the type of relationship between database tables.
|
||||
type RelationType string
|
||||
|
||||
// Supported relationship types.
|
||||
const (
|
||||
OneToOne RelationType = "one_to_one"
|
||||
OneToMany RelationType = "one_to_many"
|
||||
ManyToMany RelationType = "many_to_many"
|
||||
OneToOne RelationType = "one_to_one" // One record in table A relates to one record in table B
|
||||
OneToMany RelationType = "one_to_many" // One record in table A relates to many records in table B
|
||||
ManyToMany RelationType = "many_to_many" // Many records in table A relate to many records in table B
|
||||
)
|
||||
|
||||
// Relationship represents a relationship between two database tables.
|
||||
// Relationships can be one-to-one, one-to-many, or many-to-many.
|
||||
type Relationship struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Type RelationType `json:"type" yaml:"type" xml:"type"`
|
||||
@@ -197,11 +216,13 @@ type Relationship struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the Relationship lowercase
|
||||
// SQLName returns the relationship name in lowercase for SQL compatibility.
|
||||
func (d *Relationship) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// Constraint represents a database constraint that enforces data integrity rules.
|
||||
// Constraints can be primary keys, foreign keys, unique constraints, check constraints, or not-null constraints.
|
||||
type Constraint struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Type ConstraintType `json:"type" yaml:"type" xml:"type"`
|
||||
@@ -219,20 +240,37 @@ type Constraint struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the constraint name in lowercase for SQL compatibility.
|
||||
func (d *Constraint) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// ConstraintType represents the type of database constraint.
|
||||
type ConstraintType string
|
||||
|
||||
// Enum represents a database enumeration type with a set of allowed values.
|
||||
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"`
|
||||
}
|
||||
|
||||
// SQLName returns the enum name in lowercase for SQL compatibility.
|
||||
func (d *Enum) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// Supported constraint types.
|
||||
const (
|
||||
PrimaryKeyConstraint ConstraintType = "primary_key"
|
||||
ForeignKeyConstraint ConstraintType = "foreign_key"
|
||||
UniqueConstraint ConstraintType = "unique"
|
||||
CheckConstraint ConstraintType = "check"
|
||||
NotNullConstraint ConstraintType = "not_null"
|
||||
PrimaryKeyConstraint ConstraintType = "primary_key" // Primary key uniquely identifies each record
|
||||
ForeignKeyConstraint ConstraintType = "foreign_key" // Foreign key references another table
|
||||
UniqueConstraint ConstraintType = "unique" // Unique constraint ensures all values are different
|
||||
CheckConstraint ConstraintType = "check" // Check constraint validates data against an expression
|
||||
NotNullConstraint ConstraintType = "not_null" // Not null constraint requires a value
|
||||
)
|
||||
|
||||
// Script represents a database migration or initialization script.
|
||||
// Scripts can have dependencies and rollback capabilities.
|
||||
type Script struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description" yaml:"description" xml:"description"`
|
||||
@@ -245,11 +283,12 @@ type Script struct {
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// SQLName returns the script name in lowercase for SQL compatibility.
|
||||
func (d *Script) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// Initialize functions
|
||||
// Initialization functions for creating new model instances with proper defaults.
|
||||
|
||||
// InitDatabase initializes a new Database with empty slices
|
||||
func InitDatabase(name string) *Database {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package models
|
||||
|
||||
// =============================================================================
|
||||
// Summary/Compact Views - Lightweight views with essential fields
|
||||
// =============================================================================
|
||||
// Summary/Compact Views
|
||||
//
|
||||
// This file provides lightweight summary structures with essential fields
|
||||
// and aggregated counts for quick database schema overviews without loading
|
||||
// full object graphs.
|
||||
|
||||
// DatabaseSummary provides a compact overview of a database
|
||||
// DatabaseSummary provides a compact overview of a database with aggregated statistics.
|
||||
type DatabaseSummary struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
@@ -15,7 +17,7 @@ type DatabaseSummary struct {
|
||||
TotalColumns int `json:"total_columns" yaml:"total_columns" xml:"total_columns"`
|
||||
}
|
||||
|
||||
// ToSummary converts a Database to a DatabaseSummary
|
||||
// ToSummary converts a Database to a DatabaseSummary with calculated counts.
|
||||
func (d *Database) ToSummary() *DatabaseSummary {
|
||||
summary := &DatabaseSummary{
|
||||
Name: d.Name,
|
||||
@@ -36,7 +38,7 @@ func (d *Database) ToSummary() *DatabaseSummary {
|
||||
return summary
|
||||
}
|
||||
|
||||
// SchemaSummary provides a compact overview of a schema
|
||||
// SchemaSummary provides a compact overview of a schema with aggregated statistics.
|
||||
type SchemaSummary struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
@@ -47,7 +49,7 @@ type SchemaSummary struct {
|
||||
TotalConstraints int `json:"total_constraints" yaml:"total_constraints" xml:"total_constraints"`
|
||||
}
|
||||
|
||||
// ToSummary converts a Schema to a SchemaSummary
|
||||
// ToSummary converts a Schema to a SchemaSummary with calculated counts.
|
||||
func (s *Schema) ToSummary() *SchemaSummary {
|
||||
summary := &SchemaSummary{
|
||||
Name: s.Name,
|
||||
@@ -66,7 +68,7 @@ func (s *Schema) ToSummary() *SchemaSummary {
|
||||
return summary
|
||||
}
|
||||
|
||||
// TableSummary provides a compact overview of a table
|
||||
// TableSummary provides a compact overview of a table with aggregated statistics.
|
||||
type TableSummary struct {
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Schema string `json:"schema" yaml:"schema" xml:"schema"`
|
||||
@@ -79,7 +81,7 @@ type TableSummary struct {
|
||||
ForeignKeyCount int `json:"foreign_key_count" yaml:"foreign_key_count" xml:"foreign_key_count"`
|
||||
}
|
||||
|
||||
// ToSummary converts a Table to a TableSummary
|
||||
// ToSummary converts a Table to a TableSummary with calculated counts.
|
||||
func (t *Table) ToSummary() *TableSummary {
|
||||
summary := &TableSummary{
|
||||
Name: t.Name,
|
||||
|
||||
106
pkg/readers/bun/README.md
Normal file
106
pkg/readers/bun/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Bun Reader
|
||||
|
||||
Reads Go source files containing Bun model definitions and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Bun Reader parses Go source code files that define Bun models (structs with `bun` struct tags) and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses Bun struct tags to extract column definitions
|
||||
- Extracts table names from `bun:"table:tablename"` tags
|
||||
- Identifies primary keys, foreign keys, and indexes
|
||||
- Supports relationship detection
|
||||
- Handles both single files and directories
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/models.go",
|
||||
}
|
||||
|
||||
reader := bun.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read Bun models and convert to JSON
|
||||
relspec --input bun --in-file models/ --output json --out-file schema.json
|
||||
|
||||
# Convert Bun models to GORM
|
||||
relspec --input bun --in-file models.go --output gorm --out-file gorm_models.go
|
||||
```
|
||||
|
||||
## Supported Bun Tags
|
||||
|
||||
The reader recognizes the following Bun struct tags:
|
||||
|
||||
- `table` - Table name
|
||||
- `column` - Column name
|
||||
- `type` - SQL data type
|
||||
- `pk` - Primary key
|
||||
- `notnull` - NOT NULL constraint
|
||||
- `autoincrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `unique` - Unique constraint
|
||||
- `rel` - Relationship definition
|
||||
|
||||
## Example Bun Model
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement"`
|
||||
Username string `bun:"username,notnull,unique"`
|
||||
Email string `bun:"email,notnull"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:now()"`
|
||||
|
||||
Posts []*Post `bun:"rel:has-many,join:id=user_id"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
bun.BaseModel `bun:"table:posts,alias:p"`
|
||||
|
||||
ID int64 `bun:"id,pk"`
|
||||
UserID int64 `bun:"user_id,notnull"`
|
||||
Title string `bun:"title,notnull"`
|
||||
Content string `bun:"content"`
|
||||
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id"`
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Test files (ending in `_test.go`) are automatically excluded
|
||||
- The `bun.BaseModel` embedded struct is automatically recognized
|
||||
- Schema defaults to `public` if not specified
|
||||
@@ -382,6 +382,23 @@ func (r *Reader) isRelationship(tag string) bool {
|
||||
return strings.Contains(tag, "bun:\"rel:") || strings.Contains(tag, ",rel:")
|
||||
}
|
||||
|
||||
// getRelationType extracts the relationship type from a bun tag
|
||||
func (r *Reader) getRelationType(bunTag string) string {
|
||||
if strings.Contains(bunTag, "rel:has-many") {
|
||||
return "has-many"
|
||||
}
|
||||
if strings.Contains(bunTag, "rel:belongs-to") {
|
||||
return "belongs-to"
|
||||
}
|
||||
if strings.Contains(bunTag, "rel:has-one") {
|
||||
return "has-one"
|
||||
}
|
||||
if strings.Contains(bunTag, "rel:many-to-many") {
|
||||
return "many-to-many"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseRelationshipConstraints parses relationship fields to extract foreign key constraints
|
||||
func (r *Reader) parseRelationshipConstraints(table *models.Table, structType *ast.StructType, structMap map[string]*models.Table) {
|
||||
for _, field := range structType.Fields.List {
|
||||
@@ -409,27 +426,51 @@ func (r *Reader) parseRelationshipConstraints(table *models.Table, structType *a
|
||||
}
|
||||
|
||||
// Parse the join information: join:user_id=id
|
||||
// This means: referencedTable.user_id = thisTable.id
|
||||
// This means: thisTable.user_id = referencedTable.id
|
||||
joinInfo := r.parseJoinInfo(bunTag)
|
||||
if joinInfo == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// The FK is on the referenced table
|
||||
// Determine which table gets the FK based on relationship type
|
||||
relType := r.getRelationType(bunTag)
|
||||
|
||||
var fkTable *models.Table
|
||||
var fkColumn, refTable, refColumn string
|
||||
|
||||
switch strings.ToLower(relType) {
|
||||
case "belongs-to":
|
||||
// For belongs-to: FK is on the current table
|
||||
// join:user_id=id means table.user_id references referencedTable.id
|
||||
fkTable = table
|
||||
fkColumn = joinInfo.ForeignKey
|
||||
refTable = referencedTable.Name
|
||||
refColumn = joinInfo.ReferencedKey
|
||||
case "has-many":
|
||||
// For has-many: FK is on the referenced table
|
||||
// join:id=user_id means referencedTable.user_id references table.id
|
||||
fkTable = referencedTable
|
||||
fkColumn = joinInfo.ReferencedKey
|
||||
refTable = table.Name
|
||||
refColumn = joinInfo.ForeignKey
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
constraint := &models.Constraint{
|
||||
Name: fmt.Sprintf("fk_%s_%s", referencedTable.Name, table.Name),
|
||||
Name: fmt.Sprintf("fk_%s_%s", fkTable.Name, refTable),
|
||||
Type: models.ForeignKeyConstraint,
|
||||
Table: referencedTable.Name,
|
||||
Schema: referencedTable.Schema,
|
||||
Columns: []string{joinInfo.ForeignKey},
|
||||
ReferencedTable: table.Name,
|
||||
ReferencedSchema: table.Schema,
|
||||
ReferencedColumns: []string{joinInfo.ReferencedKey},
|
||||
Table: fkTable.Name,
|
||||
Schema: fkTable.Schema,
|
||||
Columns: []string{fkColumn},
|
||||
ReferencedTable: refTable,
|
||||
ReferencedSchema: fkTable.Schema,
|
||||
ReferencedColumns: []string{refColumn},
|
||||
OnDelete: "NO ACTION", // Bun doesn't specify this in tags
|
||||
OnUpdate: "NO ACTION",
|
||||
}
|
||||
|
||||
referencedTable.Constraints[constraint.Name] = constraint
|
||||
fkTable.Constraints[constraint.Name] = constraint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,17 +667,14 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
// - 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 if r.isNullableGoType(fieldType) {
|
||||
// SqlString, SqlInt, etc. without nullzero tag means NOT NULL
|
||||
column.NotNull = true
|
||||
} else {
|
||||
// Primitive types are NOT NULL by default
|
||||
column.NotNull = true
|
||||
column.NotNull = !r.isNullableGoType(fieldType)
|
||||
}
|
||||
|
||||
// Primary keys are always NOT NULL
|
||||
if column.IsPrimaryKey {
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
522
pkg/readers/bun/reader_test.go
Normal file
522
pkg/readers/bun/reader_test.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package bun
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "simple.go"),
|
||||
}
|
||||
|
||||
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 schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if len(table.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(table.Columns))
|
||||
}
|
||||
|
||||
// Verify id column - primary key should be NOT NULL
|
||||
idCol, exists := table.Columns["id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'id' not found")
|
||||
}
|
||||
if !idCol.IsPrimaryKey {
|
||||
t.Error("Column 'id' should be primary key")
|
||||
}
|
||||
if !idCol.AutoIncrement {
|
||||
t.Error("Column 'id' should be auto-increment")
|
||||
}
|
||||
if !idCol.NotNull {
|
||||
t.Error("Column 'id' should be NOT NULL (primary keys are always NOT NULL)")
|
||||
}
|
||||
if idCol.Type != "bigint" {
|
||||
t.Errorf("Expected id type 'bigint', got '%s'", idCol.Type)
|
||||
}
|
||||
|
||||
// Verify email column - explicit notnull tag should be NOT NULL
|
||||
emailCol, exists := table.Columns["email"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'email' not found")
|
||||
}
|
||||
if !emailCol.NotNull {
|
||||
t.Error("Column 'email' should be NOT NULL (explicit 'notnull' tag)")
|
||||
}
|
||||
if emailCol.Type != "varchar" || emailCol.Length != 255 {
|
||||
t.Errorf("Expected email type 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
}
|
||||
|
||||
// Verify name column - primitive string type should be NOT NULL by default in Bun
|
||||
nameCol, exists := table.Columns["name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'name' not found")
|
||||
}
|
||||
if !nameCol.NotNull {
|
||||
t.Error("Column 'name' should be NOT NULL (primitive string type, no nullzero tag)")
|
||||
}
|
||||
if nameCol.Type != "text" {
|
||||
t.Errorf("Expected name type 'text', got '%s'", nameCol.Type)
|
||||
}
|
||||
|
||||
// Verify age column - pointer type should be nullable (NOT NULL = false)
|
||||
ageCol, exists := table.Columns["age"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'age' not found")
|
||||
}
|
||||
if ageCol.NotNull {
|
||||
t.Error("Column 'age' should be nullable (pointer type *int)")
|
||||
}
|
||||
if ageCol.Type != "integer" {
|
||||
t.Errorf("Expected age type 'integer', got '%s'", ageCol.Type)
|
||||
}
|
||||
|
||||
// Verify is_active column - primitive bool type should be NOT NULL by default
|
||||
isActiveCol, exists := table.Columns["is_active"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'is_active' not found")
|
||||
}
|
||||
if !isActiveCol.NotNull {
|
||||
t.Error("Column 'is_active' should be NOT NULL (primitive bool type, no nullzero tag)")
|
||||
}
|
||||
if isActiveCol.Type != "boolean" {
|
||||
t.Errorf("Expected is_active type 'boolean', got '%s'", isActiveCol.Type)
|
||||
}
|
||||
|
||||
// Verify created_at column - time.Time should be NOT NULL by default
|
||||
createdAtCol, exists := table.Columns["created_at"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'created_at' not found")
|
||||
}
|
||||
if !createdAtCol.NotNull {
|
||||
t.Error("Column 'created_at' should be NOT NULL (time.Time is NOT NULL by default)")
|
||||
}
|
||||
if createdAtCol.Type != "timestamp" {
|
||||
t.Errorf("Expected created_at type 'timestamp', got '%s'", createdAtCol.Type)
|
||||
}
|
||||
|
||||
// Verify unique index on email
|
||||
if len(table.Indexes) < 1 {
|
||||
t.Error("Expected at least 1 index on users table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "complex.go"),
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Verify schema
|
||||
if len(db.Schemas) != 1 {
|
||||
t.Fatalf("Expected 1 schema, got %d", len(db.Schemas))
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
// Verify tables
|
||||
if len(schema.Tables) != 3 {
|
||||
t.Fatalf("Expected 3 tables, got %d", len(schema.Tables))
|
||||
}
|
||||
|
||||
// Find tables
|
||||
var usersTable, postsTable, commentsTable *models.Table
|
||||
for _, table := range schema.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 - test NOT NULL logic for various field types
|
||||
if len(usersTable.Columns) != 10 {
|
||||
t.Errorf("Expected 10 columns in users table, got %d", len(usersTable.Columns))
|
||||
}
|
||||
|
||||
// username - NOT NULL (explicit notnull tag)
|
||||
usernameCol, exists := usersTable.Columns["username"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'username' not found")
|
||||
}
|
||||
if !usernameCol.NotNull {
|
||||
t.Error("Column 'username' should be NOT NULL (explicit 'notnull' tag)")
|
||||
}
|
||||
|
||||
// first_name - nullable (pointer type)
|
||||
firstNameCol, exists := usersTable.Columns["first_name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'first_name' not found")
|
||||
}
|
||||
if firstNameCol.NotNull {
|
||||
t.Error("Column 'first_name' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// last_name - nullable (pointer type)
|
||||
lastNameCol, exists := usersTable.Columns["last_name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'last_name' not found")
|
||||
}
|
||||
if lastNameCol.NotNull {
|
||||
t.Error("Column 'last_name' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// bio - nullable (pointer type)
|
||||
bioCol, exists := usersTable.Columns["bio"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'bio' not found")
|
||||
}
|
||||
if bioCol.NotNull {
|
||||
t.Error("Column 'bio' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// is_active - NOT NULL (primitive bool without nullzero)
|
||||
isActiveCol, exists := usersTable.Columns["is_active"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'is_active' not found")
|
||||
}
|
||||
if !isActiveCol.NotNull {
|
||||
t.Error("Column 'is_active' should be NOT NULL (primitive bool type without nullzero)")
|
||||
}
|
||||
|
||||
// Verify users table indexes
|
||||
if len(usersTable.Indexes) < 1 {
|
||||
t.Error("Expected at least 1 index on users table")
|
||||
}
|
||||
|
||||
// Verify posts table
|
||||
if len(postsTable.Columns) != 11 {
|
||||
t.Errorf("Expected 11 columns in posts table, got %d", len(postsTable.Columns))
|
||||
}
|
||||
|
||||
// excerpt - nullable (pointer type)
|
||||
excerptCol, exists := postsTable.Columns["excerpt"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'excerpt' not found")
|
||||
}
|
||||
if excerptCol.NotNull {
|
||||
t.Error("Column 'excerpt' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// published - NOT NULL (primitive bool without nullzero)
|
||||
publishedCol, exists := postsTable.Columns["published"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'published' not found")
|
||||
}
|
||||
if !publishedCol.NotNull {
|
||||
t.Error("Column 'published' should be NOT NULL (primitive bool type without nullzero)")
|
||||
}
|
||||
|
||||
// published_at - nullable (has nullzero tag)
|
||||
publishedAtCol, exists := postsTable.Columns["published_at"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'published_at' not found")
|
||||
}
|
||||
if publishedAtCol.NotNull {
|
||||
t.Error("Column 'published_at' should be nullable (has nullzero tag)")
|
||||
}
|
||||
|
||||
// view_count - NOT NULL (primitive int64 without nullzero)
|
||||
viewCountCol, exists := postsTable.Columns["view_count"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'view_count' not found")
|
||||
}
|
||||
if !viewCountCol.NotNull {
|
||||
t.Error("Column 'view_count' should be NOT NULL (primitive int64 type without nullzero)")
|
||||
}
|
||||
|
||||
// Verify posts table indexes
|
||||
if len(postsTable.Indexes) < 1 {
|
||||
t.Error("Expected at least 1 index on posts table")
|
||||
}
|
||||
|
||||
// Verify comments table
|
||||
if len(commentsTable.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns in comments table, got %d", len(commentsTable.Columns))
|
||||
}
|
||||
|
||||
// user_id - nullable (pointer type)
|
||||
userIDCol, exists := commentsTable.Columns["user_id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'user_id' not found in comments table")
|
||||
}
|
||||
if userIDCol.NotNull {
|
||||
t.Error("Column 'user_id' should be nullable (pointer type *int64)")
|
||||
}
|
||||
|
||||
// post_id - NOT NULL (explicit notnull tag)
|
||||
postIDCol, exists := commentsTable.Columns["post_id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'post_id' not found in comments table")
|
||||
}
|
||||
if !postIDCol.NotNull {
|
||||
t.Error("Column 'post_id' should be NOT NULL (explicit 'notnull' tag)")
|
||||
}
|
||||
|
||||
// Verify foreign key constraints are created from relationship tags
|
||||
// In Bun, relationships are defined with rel: tags
|
||||
// The constraints should be created on the referenced tables
|
||||
if len(postsTable.Constraints) > 0 {
|
||||
// Check if FK constraint exists
|
||||
var fkPostsUser *models.Constraint
|
||||
for _, c := range postsTable.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint && c.ReferencedTable == "users" {
|
||||
fkPostsUser = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fkPostsUser != nil {
|
||||
if len(fkPostsUser.Columns) != 1 || fkPostsUser.Columns[0] != "user_id" {
|
||||
t.Error("Expected FK column 'user_id'")
|
||||
}
|
||||
if len(fkPostsUser.ReferencedColumns) != 1 || fkPostsUser.ReferencedColumns[0] != "id" {
|
||||
t.Error("Expected FK referenced column 'id'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(commentsTable.Constraints) > 0 {
|
||||
// Check if FK constraints exist
|
||||
var fkCommentsPost, fkCommentsUser *models.Constraint
|
||||
for _, c := range commentsTable.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint {
|
||||
if c.ReferencedTable == "posts" {
|
||||
fkCommentsPost = c
|
||||
} else if c.ReferencedTable == "users" {
|
||||
fkCommentsUser = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fkCommentsPost != nil {
|
||||
if len(fkCommentsPost.Columns) != 1 || fkCommentsPost.Columns[0] != "post_id" {
|
||||
t.Error("Expected FK column 'post_id'")
|
||||
}
|
||||
}
|
||||
|
||||
if fkCommentsUser != nil {
|
||||
if len(fkCommentsUser.Columns) != 1 || fkCommentsUser.Columns[0] != "user_id" {
|
||||
t.Error("Expected FK column 'user_id'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "simple.go"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSchema() error = %v", err)
|
||||
}
|
||||
|
||||
if schema == nil {
|
||||
t.Fatal("ReadSchema() returned nil schema")
|
||||
}
|
||||
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
if len(schema.Tables) != 1 {
|
||||
t.Errorf("Expected 1 table, got %d", len(schema.Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "simple.go"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
table, err := reader.ReadTable()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadTable() error = %v", err)
|
||||
}
|
||||
|
||||
if table == nil {
|
||||
t.Fatal("ReadTable() returned nil table")
|
||||
}
|
||||
|
||||
if table.Name != "users" {
|
||||
t.Errorf("Expected table name 'users', got '%s'", table.Name)
|
||||
}
|
||||
|
||||
if len(table.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(table.Columns))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_Directory(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun"),
|
||||
}
|
||||
|
||||
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 read both simple.go and complex.go
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("Expected at least one schema")
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
// Should have at least 3 tables from complex.go (users, posts, comments)
|
||||
// plus 1 from simple.go (users) - but same table name, so may be overwritten
|
||||
if len(schema.Tables) < 3 {
|
||||
t.Errorf("Expected at least 3 tables, got %d", len(schema.Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_InvalidPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/file.go",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid file path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_EmptyPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty file path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_NullableTypes(t *testing.T) {
|
||||
// This test specifically verifies the NOT NULL logic changes
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "complex.go"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
// Find posts table
|
||||
var postsTable *models.Table
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == "posts" {
|
||||
postsTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if postsTable == nil {
|
||||
t.Fatal("Posts table not found")
|
||||
}
|
||||
|
||||
// Test all nullability scenarios
|
||||
tests := []struct {
|
||||
column string
|
||||
notNull bool
|
||||
reason string
|
||||
}{
|
||||
{"id", true, "primary key"},
|
||||
{"user_id", true, "explicit notnull tag"},
|
||||
{"title", true, "explicit notnull tag"},
|
||||
{"slug", true, "explicit notnull tag"},
|
||||
{"content", true, "explicit notnull tag"},
|
||||
{"excerpt", false, "pointer type *string"},
|
||||
{"published", true, "primitive bool without nullzero"},
|
||||
{"view_count", true, "primitive int64 without nullzero"},
|
||||
{"published_at", false, "has nullzero tag"},
|
||||
{"created_at", true, "time.Time without nullzero"},
|
||||
{"updated_at", true, "time.Time without nullzero"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
col, exists := postsTable.Columns[tt.column]
|
||||
if !exists {
|
||||
t.Errorf("Column '%s' not found", tt.column)
|
||||
continue
|
||||
}
|
||||
|
||||
if col.NotNull != tt.notNull {
|
||||
if tt.notNull {
|
||||
t.Errorf("Column '%s' should be NOT NULL (%s), but NotNull=%v",
|
||||
tt.column, tt.reason, col.NotNull)
|
||||
} else {
|
||||
t.Errorf("Column '%s' should be nullable (%s), but NotNull=%v",
|
||||
tt.column, tt.reason, col.NotNull)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
pkg/readers/dbml/README.md
Normal file
101
pkg/readers/dbml/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# DBML Reader
|
||||
|
||||
Reads Database Markup Language (DBML) files and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DBML Reader parses `.dbml` files that define database schemas using the DBML syntax (used by dbdiagram.io) and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses DBML syntax
|
||||
- Extracts tables, columns, and relationships
|
||||
- Supports DBML-specific features:
|
||||
- Table groups and notes
|
||||
- Enum definitions
|
||||
- Indexes
|
||||
- Foreign key relationships
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/schema.dbml",
|
||||
}
|
||||
|
||||
reader := dbml.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read DBML file and convert to JSON
|
||||
relspec --input dbml --in-file schema.dbml --output json --out-file schema.json
|
||||
|
||||
# Convert DBML to GORM models
|
||||
relspec --input dbml --in-file database.dbml --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Example DBML File
|
||||
|
||||
```dbml
|
||||
Table users {
|
||||
id bigserial [pk, increment]
|
||||
username varchar(50) [not null, unique]
|
||||
email varchar(100) [not null]
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
|
||||
Note: 'Users table'
|
||||
}
|
||||
|
||||
Table posts {
|
||||
id bigserial [pk]
|
||||
user_id bigint [not null, ref: > users.id]
|
||||
title varchar(200) [not null]
|
||||
content text
|
||||
|
||||
indexes {
|
||||
user_id
|
||||
(user_id, created_at) [name: 'idx_user_posts']
|
||||
}
|
||||
}
|
||||
|
||||
Ref: posts.user_id > users.id [delete: cascade]
|
||||
```
|
||||
|
||||
## DBML Features Supported
|
||||
|
||||
- Table definitions with columns
|
||||
- Primary keys (`pk`)
|
||||
- Not null constraints (`not null`)
|
||||
- Unique constraints (`unique`)
|
||||
- Default values (`default`)
|
||||
- Inline references (`ref`)
|
||||
- Standalone `Ref` blocks
|
||||
- Indexes and composite indexes
|
||||
- Table notes and column notes
|
||||
- Enums
|
||||
|
||||
## Notes
|
||||
|
||||
- DBML is designed for database documentation and diagramming
|
||||
- Schema name defaults to `public`
|
||||
- Relationship cardinality is preserved
|
||||
96
pkg/readers/dctx/README.md
Normal file
96
pkg/readers/dctx/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# DCTX Reader
|
||||
|
||||
Reads Clarion database dictionary (DCTX) files and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DCTX Reader parses Clarion dictionary files (`.dctx`) that define database structures in the Clarion development system and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses Clarion DCTX XML format
|
||||
- Extracts file (table) and field (column) definitions
|
||||
- Supports Clarion data types
|
||||
- Handles keys (indexes) and relationships
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dctx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/database.dctx",
|
||||
}
|
||||
|
||||
reader := dctx.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read DCTX file and convert to JSON
|
||||
relspec --input dctx --in-file legacy.dctx --output json --out-file schema.json
|
||||
|
||||
# Convert DCTX to GORM models for migration
|
||||
relspec --input dctx --in-file app.dctx --output gorm --out-file models.go
|
||||
|
||||
# Export DCTX to PostgreSQL DDL
|
||||
relspec --input dctx --in-file database.dctx --output pgsql --out-file schema.sql
|
||||
```
|
||||
|
||||
## Example DCTX Structure
|
||||
|
||||
DCTX files are XML-based Clarion dictionary files that define:
|
||||
|
||||
- Files (equivalent to tables)
|
||||
- Fields (columns) with Clarion-specific types
|
||||
- Keys (indexes)
|
||||
- Relationships between files
|
||||
|
||||
Common Clarion data types:
|
||||
- `STRING` - Fixed-length string
|
||||
- `CSTRING` - C-style null-terminated string
|
||||
- `LONG` - 32-bit integer
|
||||
- `SHORT` - 16-bit integer
|
||||
- `DECIMAL` - Decimal number
|
||||
- `REAL` - Floating point
|
||||
- `DATE` - Date field
|
||||
- `TIME` - Time field
|
||||
|
||||
## Type Mapping
|
||||
|
||||
The reader automatically maps Clarion data types to standard SQL types:
|
||||
|
||||
| Clarion Type | SQL Type |
|
||||
|--------------|----------|
|
||||
| STRING | VARCHAR |
|
||||
| CSTRING | VARCHAR |
|
||||
| LONG | INTEGER |
|
||||
| SHORT | SMALLINT |
|
||||
| DECIMAL | NUMERIC |
|
||||
| REAL | REAL |
|
||||
| DATE | DATE |
|
||||
| TIME | TIME |
|
||||
|
||||
## Notes
|
||||
|
||||
- DCTX is specific to Clarion development platform
|
||||
- Useful for migrating legacy Clarion applications
|
||||
- Schema name defaults to `public`
|
||||
- Preserves field properties and constraints where possible
|
||||
96
pkg/readers/drawdb/README.md
Normal file
96
pkg/readers/drawdb/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# DrawDB Reader
|
||||
|
||||
Reads DrawDB schema files and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DrawDB Reader parses JSON files exported from DrawDB (a free online database design tool) and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses DrawDB JSON format
|
||||
- Extracts tables, fields, and relationships
|
||||
- Supports DrawDB-specific metadata
|
||||
- Preserves visual layout information
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/diagram.json",
|
||||
}
|
||||
|
||||
reader := drawdb.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read DrawDB export and convert to JSON schema
|
||||
relspec --input drawdb --in-file diagram.json --output json --out-file schema.json
|
||||
|
||||
# Convert DrawDB design to GORM models
|
||||
relspec --input drawdb --in-file design.json --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Example DrawDB Export
|
||||
|
||||
DrawDB exports database designs as JSON files containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "users",
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "BIGINT",
|
||||
"primary": true,
|
||||
"autoIncrement": true
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"type": "VARCHAR",
|
||||
"size": 50,
|
||||
"notNull": true,
|
||||
"unique": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source": "posts",
|
||||
"target": "users",
|
||||
"type": "many-to-one"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- DrawDB is a free online database designer at drawdb.vercel.app
|
||||
- Export format preserves visual design metadata
|
||||
- Useful for converting visual designs to code
|
||||
- Schema defaults to `public`
|
||||
90
pkg/readers/drizzle/README.md
Normal file
90
pkg/readers/drizzle/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Drizzle Reader
|
||||
|
||||
Reads TypeScript/JavaScript files containing Drizzle ORM schema definitions and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Drizzle Reader parses Drizzle ORM schema files (TypeScript/JavaScript) that define database tables using Drizzle's schema builder and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses Drizzle schema definitions
|
||||
- Extracts table, column, and relationship information
|
||||
- Supports various Drizzle column types
|
||||
- Handles constraints and indexes
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/schema.ts",
|
||||
}
|
||||
|
||||
reader := drizzle.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read Drizzle schema and convert to JSON
|
||||
relspec --input drizzle --in-file schema.ts --output json --out-file schema.json
|
||||
|
||||
# Convert Drizzle to GORM models
|
||||
relspec --input drizzle --in-file schema/ --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Example Drizzle Schema
|
||||
|
||||
```typescript
|
||||
import { pgTable, serial, varchar, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
username: varchar('username', { length: 50 }).notNull().unique(),
|
||||
email: varchar('email', { length: 100 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const posts = pgTable('posts', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
content: text('content'),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
posts: many(posts),
|
||||
}));
|
||||
|
||||
export const postsRelations = relations(posts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [posts.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Supports both PostgreSQL and MySQL Drizzle schemas
|
||||
- Extracts relationship information from `relations` definitions
|
||||
- Schema defaults to `public` for PostgreSQL
|
||||
619
pkg/readers/drizzle/reader.go
Normal file
619
pkg/readers/drizzle/reader.go
Normal file
@@ -0,0 +1,619 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for Drizzle schema format
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
}
|
||||
|
||||
// NewReader creates a new Drizzle reader with the given options
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads and parses Drizzle schema input, returning a Database model
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle reader")
|
||||
}
|
||||
|
||||
// Check if it's a file or directory
|
||||
info, err := os.Stat(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat path: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Read all .ts files in the directory
|
||||
return r.readDirectory(r.options.FilePath)
|
||||
}
|
||||
|
||||
// Read single file
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return r.parseDrizzle(string(content))
|
||||
}
|
||||
|
||||
// ReadSchema reads and parses Drizzle schema input, returning a Schema model
|
||||
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 schemas found in Drizzle schema")
|
||||
}
|
||||
|
||||
// Return the first schema
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable reads and parses Drizzle schema input, returning a Table model
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in Drizzle schema")
|
||||
}
|
||||
|
||||
// Return the first table
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// readDirectory reads all .ts files in a directory and parses them
|
||||
func (r *Reader) readDirectory(dirPath string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
db.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// Default schema for Drizzle
|
||||
schema := models.InitSchema("public")
|
||||
schema.Enums = make([]*models.Enum, 0)
|
||||
|
||||
// Read all .ts files
|
||||
files, err := filepath.Glob(filepath.Join(dirPath, "*.ts"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to glob directory: %w", err)
|
||||
}
|
||||
|
||||
// Parse each file
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Parse and merge into schema
|
||||
fileDB, err := r.parseDrizzle(string(content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse file %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Merge schemas
|
||||
if len(fileDB.Schemas) > 0 {
|
||||
fileSchema := fileDB.Schemas[0]
|
||||
schema.Tables = append(schema.Tables, fileSchema.Tables...)
|
||||
schema.Enums = append(schema.Enums, fileSchema.Enums...)
|
||||
}
|
||||
}
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// parseDrizzle parses Drizzle schema content and returns a Database model
|
||||
func (r *Reader) parseDrizzle(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
db.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// Default schema for Drizzle (PostgreSQL)
|
||||
schema := models.InitSchema("public")
|
||||
schema.Enums = make([]*models.Enum, 0)
|
||||
db.DatabaseType = models.PostgresqlDatabaseType
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
// Regex patterns
|
||||
// Match: export const users = pgTable('users', {
|
||||
pgTableRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*pgTable\s*\(\s*['"](\w+)['"]`)
|
||||
// Match: export const userRole = pgEnum('UserRole', ['admin', 'user']);
|
||||
pgEnumRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*pgEnum\s*\(\s*['"](\w+)['"]`)
|
||||
|
||||
// State tracking
|
||||
var currentTable *models.Table
|
||||
var currentTableVarName string
|
||||
var inTableBlock bool
|
||||
var blockDepth int
|
||||
var tableLines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for pgEnum definition
|
||||
if matches := pgEnumRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
enum := r.parsePgEnum(trimmed, matches)
|
||||
if enum != nil {
|
||||
schema.Enums = append(schema.Enums, enum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for pgTable definition
|
||||
if matches := pgTableRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
varName := matches[1]
|
||||
tableName := matches[2]
|
||||
|
||||
currentTableVarName = varName
|
||||
currentTable = models.InitTable(tableName, "public")
|
||||
inTableBlock = true
|
||||
// Count braces in the first line
|
||||
blockDepth = strings.Count(line, "{") - strings.Count(line, "}")
|
||||
tableLines = []string{line}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're in a table block, accumulate lines
|
||||
if inTableBlock {
|
||||
tableLines = append(tableLines, line)
|
||||
|
||||
// Track brace depth
|
||||
blockDepth += strings.Count(line, "{")
|
||||
blockDepth -= strings.Count(line, "}")
|
||||
|
||||
// Check if we've closed the table definition
|
||||
if blockDepth < 0 || (blockDepth == 0 && strings.Contains(line, ");")) {
|
||||
// Parse the complete table block
|
||||
if currentTable != nil {
|
||||
r.parseTableBlock(tableLines, currentTable, currentTableVarName)
|
||||
schema.Tables = append(schema.Tables, currentTable)
|
||||
currentTable = nil
|
||||
}
|
||||
inTableBlock = false
|
||||
tableLines = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// parsePgEnum parses a pgEnum definition
|
||||
func (r *Reader) parsePgEnum(line string, matches []string) *models.Enum {
|
||||
// matches[1] = variable name
|
||||
// matches[2] = enum name
|
||||
|
||||
enumName := matches[2]
|
||||
|
||||
// Extract values from the array
|
||||
// Example: pgEnum('UserRole', ['admin', 'user', 'guest'])
|
||||
valuesRegex := regexp.MustCompile(`\[(.*?)\]`)
|
||||
valuesMatch := valuesRegex.FindStringSubmatch(line)
|
||||
if valuesMatch == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
valuesStr := valuesMatch[1]
|
||||
// Split by comma and clean up
|
||||
valueParts := strings.Split(valuesStr, ",")
|
||||
values := make([]string, 0)
|
||||
for _, part := range valueParts {
|
||||
// Remove quotes and whitespace
|
||||
cleaned := strings.TrimSpace(part)
|
||||
cleaned = strings.Trim(cleaned, "'\"")
|
||||
if cleaned != "" {
|
||||
values = append(values, cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Enum{
|
||||
Name: enumName,
|
||||
Values: values,
|
||||
Schema: "public",
|
||||
}
|
||||
}
|
||||
|
||||
// parseTableBlock parses a complete pgTable definition block
|
||||
func (r *Reader) parseTableBlock(lines []string, table *models.Table, tableVarName string) {
|
||||
// Join all lines into a single string for easier parsing
|
||||
fullText := strings.Join(lines, "\n")
|
||||
|
||||
// Extract the columns block and index callback separately
|
||||
// The structure is: pgTable('name', { columns }, (table) => [indexes])
|
||||
|
||||
// Find the main object block (columns)
|
||||
columnsStart := strings.Index(fullText, "{")
|
||||
if columnsStart == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching closing brace for columns
|
||||
depth := 0
|
||||
columnsEnd := -1
|
||||
for i := columnsStart; i < len(fullText); i++ {
|
||||
if fullText[i] == '{' {
|
||||
depth++
|
||||
} else if fullText[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
columnsEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if columnsEnd == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
columnsBlock := fullText[columnsStart+1 : columnsEnd]
|
||||
|
||||
// Parse columns
|
||||
r.parseColumnsBlock(columnsBlock, table, tableVarName)
|
||||
|
||||
// Check for index callback: , (table) => [ or , ({ col1, col2 }) => [
|
||||
// Match: }, followed by arrow function with any parameters
|
||||
// Use (?s) flag to make . match newlines
|
||||
indexCallbackRegex := regexp.MustCompile(`(?s)}\s*,\s*\(.*?\)\s*=>\s*\[`)
|
||||
if indexCallbackRegex.MatchString(fullText[columnsEnd:]) {
|
||||
// Find the index array
|
||||
indexStart := strings.Index(fullText[columnsEnd:], "[")
|
||||
if indexStart != -1 {
|
||||
indexStart += columnsEnd
|
||||
indexDepth := 0
|
||||
indexEnd := -1
|
||||
for i := indexStart; i < len(fullText); i++ {
|
||||
if fullText[i] == '[' {
|
||||
indexDepth++
|
||||
} else if fullText[i] == ']' {
|
||||
indexDepth--
|
||||
if indexDepth == 0 {
|
||||
indexEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if indexEnd != -1 {
|
||||
indexBlock := fullText[indexStart+1 : indexEnd]
|
||||
r.parseIndexBlock(indexBlock, table, tableVarName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseColumnsBlock parses the columns block of a table
|
||||
func (r *Reader) parseColumnsBlock(block string, table *models.Table, tableVarName string) {
|
||||
// Split by lines and parse each column definition
|
||||
lines := strings.Split(block, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match: fieldName: columnType('columnName').modifier().modifier(),
|
||||
// Example: id: integer('id').primaryKey(),
|
||||
columnRegex := regexp.MustCompile(`(\w+):\s*(\w+)\s*\(`)
|
||||
matches := columnRegex.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := matches[1]
|
||||
columnType := matches[2]
|
||||
|
||||
// Parse the column definition
|
||||
col := r.parseColumnDefinition(trimmed, fieldName, columnType, table)
|
||||
if col != nil {
|
||||
table.Columns[col.Name] = col
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseColumnDefinition parses a single column definition line
|
||||
func (r *Reader) parseColumnDefinition(line, fieldName, drizzleType string, table *models.Table) *models.Column {
|
||||
// Check for enum column syntax: pgEnum('EnumName')('column_name')
|
||||
enumRegex := regexp.MustCompile(`pgEnum\s*\(['"](\w+)['"]\)\s*\(['"](\w+)['"]\)`)
|
||||
if enumMatch := enumRegex.FindStringSubmatch(line); enumMatch != nil {
|
||||
enumName := enumMatch[1]
|
||||
columnName := enumMatch[2]
|
||||
|
||||
column := models.InitColumn(columnName, table.Name, table.Schema)
|
||||
column.Type = enumName
|
||||
column.NotNull = false
|
||||
|
||||
// Parse modifiers
|
||||
r.parseColumnModifiers(line, column, table)
|
||||
return column
|
||||
}
|
||||
|
||||
// Extract column name from the first argument
|
||||
// Example: integer('id')
|
||||
nameRegex := regexp.MustCompile(`\w+\s*\(['"](\w+)['"]\)`)
|
||||
nameMatch := nameRegex.FindStringSubmatch(line)
|
||||
if nameMatch == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
columnName := nameMatch[1]
|
||||
column := models.InitColumn(columnName, table.Name, table.Schema)
|
||||
|
||||
// Map Drizzle type to SQL type
|
||||
column.Type = r.drizzleTypeToSQL(drizzleType)
|
||||
|
||||
// Default: columns are nullable unless specified
|
||||
column.NotNull = false
|
||||
|
||||
// Parse modifiers
|
||||
r.parseColumnModifiers(line, column, table)
|
||||
|
||||
return column
|
||||
}
|
||||
|
||||
// drizzleTypeToSQL converts Drizzle column types to SQL types
|
||||
func (r *Reader) drizzleTypeToSQL(drizzleType string) string {
|
||||
typeMap := map[string]string{
|
||||
// Integer types
|
||||
"integer": "integer",
|
||||
"bigint": "bigint",
|
||||
"smallint": "smallint",
|
||||
|
||||
// Serial types
|
||||
"serial": "serial",
|
||||
"bigserial": "bigserial",
|
||||
"smallserial": "smallserial",
|
||||
|
||||
// Numeric types
|
||||
"numeric": "numeric",
|
||||
"real": "real",
|
||||
"doublePrecision": "double precision",
|
||||
|
||||
// Character types
|
||||
"text": "text",
|
||||
"varchar": "varchar",
|
||||
"char": "char",
|
||||
|
||||
// Boolean
|
||||
"boolean": "boolean",
|
||||
|
||||
// Binary
|
||||
"bytea": "bytea",
|
||||
|
||||
// JSON
|
||||
"json": "json",
|
||||
"jsonb": "jsonb",
|
||||
|
||||
// Date/Time
|
||||
"time": "time",
|
||||
"timestamp": "timestamp",
|
||||
"date": "date",
|
||||
"interval": "interval",
|
||||
|
||||
// UUID
|
||||
"uuid": "uuid",
|
||||
|
||||
// Geometric
|
||||
"point": "point",
|
||||
"line": "line",
|
||||
}
|
||||
|
||||
if sqlType, ok := typeMap[drizzleType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// If not found, might be an enum - return as-is
|
||||
return drizzleType
|
||||
}
|
||||
|
||||
// parseColumnModifiers parses column modifiers like .primaryKey(), .notNull(), etc.
|
||||
func (r *Reader) parseColumnModifiers(line string, column *models.Column, table *models.Table) {
|
||||
// Check for .primaryKey()
|
||||
if strings.Contains(line, ".primaryKey()") {
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
// Check for .notNull()
|
||||
if strings.Contains(line, ".notNull()") {
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
// Check for .unique()
|
||||
if strings.Contains(line, ".unique()") {
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("uq_%s", column.Name),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = table.Schema
|
||||
uniqueConstraint.Table = table.Name
|
||||
uniqueConstraint.Columns = []string{column.Name}
|
||||
table.Constraints[uniqueConstraint.Name] = uniqueConstraint
|
||||
}
|
||||
|
||||
// Check for .default(...)
|
||||
// Need to handle nested backticks and parentheses in SQL expressions
|
||||
defaultIdx := strings.Index(line, ".default(")
|
||||
if defaultIdx != -1 {
|
||||
start := defaultIdx + len(".default(")
|
||||
depth := 1
|
||||
inBacktick := false
|
||||
i := start
|
||||
|
||||
for i < len(line) && depth > 0 {
|
||||
ch := line[i]
|
||||
if ch == '`' {
|
||||
inBacktick = !inBacktick
|
||||
} else if !inBacktick {
|
||||
switch ch {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if depth == 0 {
|
||||
defaultValue := strings.TrimSpace(line[start : i-1])
|
||||
r.parseDefaultValue(defaultValue, column)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .generatedAlwaysAsIdentity()
|
||||
if strings.Contains(line, ".generatedAlwaysAsIdentity()") {
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
|
||||
// Check for .references(() => otherTable.column)
|
||||
referencesRegex := regexp.MustCompile(`\.references\(\(\)\s*=>\s*(\w+)\.(\w+)\)`)
|
||||
if matches := referencesRegex.FindStringSubmatch(line); matches != nil {
|
||||
refTableVar := matches[1]
|
||||
refColumn := matches[2]
|
||||
|
||||
// Create FK constraint
|
||||
constraintName := fmt.Sprintf("fk_%s_%s", table.Name, column.Name)
|
||||
constraint := models.InitConstraint(constraintName, models.ForeignKeyConstraint)
|
||||
constraint.Schema = table.Schema
|
||||
constraint.Table = table.Name
|
||||
constraint.Columns = []string{column.Name}
|
||||
constraint.ReferencedSchema = table.Schema // Assume same schema
|
||||
constraint.ReferencedTable = r.varNameToTableName(refTableVar)
|
||||
constraint.ReferencedColumns = []string{refColumn}
|
||||
|
||||
table.Constraints[constraint.Name] = constraint
|
||||
}
|
||||
}
|
||||
|
||||
// parseDefaultValue parses a default value expression
|
||||
func (r *Reader) parseDefaultValue(defaultExpr string, column *models.Column) {
|
||||
defaultExpr = strings.TrimSpace(defaultExpr)
|
||||
|
||||
// Handle SQL expressions like sql`now()`
|
||||
sqlRegex := regexp.MustCompile("sql`([^`]+)`")
|
||||
if match := sqlRegex.FindStringSubmatch(defaultExpr); match != nil {
|
||||
column.Default = match[1]
|
||||
return
|
||||
}
|
||||
|
||||
// Handle boolean values
|
||||
if defaultExpr == "true" {
|
||||
column.Default = true
|
||||
return
|
||||
}
|
||||
if defaultExpr == "false" {
|
||||
column.Default = false
|
||||
return
|
||||
}
|
||||
|
||||
// Handle string literals
|
||||
if strings.HasPrefix(defaultExpr, "'") && strings.HasSuffix(defaultExpr, "'") {
|
||||
column.Default = defaultExpr[1 : len(defaultExpr)-1]
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(defaultExpr, "\"") && strings.HasSuffix(defaultExpr, "\"") {
|
||||
column.Default = defaultExpr[1 : len(defaultExpr)-1]
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse as number
|
||||
column.Default = defaultExpr
|
||||
}
|
||||
|
||||
// parseIndexBlock parses the index callback block
|
||||
func (r *Reader) parseIndexBlock(block string, table *models.Table, tableVarName string) {
|
||||
// Split by lines
|
||||
lines := strings.Split(block, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match: index('index_name').on(table.col1, table.col2)
|
||||
// or: uniqueIndex('index_name').on(table.col1, table.col2)
|
||||
indexRegex := regexp.MustCompile(`(uniqueIndex|index)\s*\(['"](\w+)['"]\)\s*\.on\s*\((.*?)\)`)
|
||||
matches := indexRegex.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
indexType := matches[1]
|
||||
indexName := matches[2]
|
||||
columnsStr := matches[3]
|
||||
|
||||
// Parse column list
|
||||
columnParts := strings.Split(columnsStr, ",")
|
||||
columns := make([]string, 0)
|
||||
for _, part := range columnParts {
|
||||
// Remove table prefix: table.column -> column
|
||||
cleaned := strings.TrimSpace(part)
|
||||
if strings.Contains(cleaned, ".") {
|
||||
parts := strings.Split(cleaned, ".")
|
||||
cleaned = parts[len(parts)-1]
|
||||
}
|
||||
columns = append(columns, cleaned)
|
||||
}
|
||||
|
||||
if indexType == "uniqueIndex" {
|
||||
// Create unique constraint
|
||||
constraint := models.InitConstraint(indexName, models.UniqueConstraint)
|
||||
constraint.Schema = table.Schema
|
||||
constraint.Table = table.Name
|
||||
constraint.Columns = columns
|
||||
table.Constraints[constraint.Name] = constraint
|
||||
} else {
|
||||
// Create index
|
||||
index := models.InitIndex(indexName, table.Name, table.Schema)
|
||||
index.Columns = columns
|
||||
index.Unique = false
|
||||
table.Indexes[index.Name] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// varNameToTableName converts a variable name to a table name
|
||||
// For now, just return as-is (could add inflection later)
|
||||
func (r *Reader) varNameToTableName(varName string) string {
|
||||
// TODO: Could add conversion logic here if needed
|
||||
// For now, assume variable name matches table name
|
||||
return varName
|
||||
}
|
||||
141
pkg/readers/gorm/README.md
Normal file
141
pkg/readers/gorm/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# GORM Reader
|
||||
|
||||
Reads Go source files containing GORM model definitions and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The GORM Reader parses Go source code files that define GORM models (structs with `gorm` struct tags) and converts them into RelSpec's internal database model representation. It supports reading from individual files or entire directories.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses GORM struct tags to extract column definitions
|
||||
- Extracts table names from `TableName()` methods
|
||||
- Identifies primary keys, foreign keys, and indexes
|
||||
- Supports relationship detection (has-many, belongs-to)
|
||||
- Handles both single files and directories
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read from a single file
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/models.go",
|
||||
}
|
||||
|
||||
reader := gorm.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### Reading from Directory
|
||||
|
||||
```go
|
||||
// Read all .go files from a directory
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/models/",
|
||||
}
|
||||
|
||||
reader := gorm.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read GORM models and convert to JSON
|
||||
relspec --input gorm --in-file models/ --output json --out-file schema.json
|
||||
|
||||
# Convert GORM models to Bun
|
||||
relspec --input gorm --in-file models.go --output bun --out-file bun_models.go
|
||||
```
|
||||
|
||||
## Supported GORM Tags
|
||||
|
||||
The reader recognizes the following GORM struct tags:
|
||||
|
||||
- `column` - Column name
|
||||
- `type` - SQL data type (e.g., `varchar(255)`, `bigint`)
|
||||
- `primaryKey` or `primary_key` - Mark as primary key
|
||||
- `not null` - NOT NULL constraint
|
||||
- `autoIncrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `size` - Column size/length
|
||||
- `index` - Create index
|
||||
- `uniqueIndex` - Create unique index
|
||||
- `unique` - Unique constraint
|
||||
- `foreignKey` - Foreign key column
|
||||
- `references` - Referenced column
|
||||
- `constraint` - Constraint behavior (OnDelete, OnUpdate)
|
||||
|
||||
## Example GORM Model
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ModelUser struct {
|
||||
gorm.Model
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement"`
|
||||
Username string `gorm:"column:username;type:varchar(50);not null;uniqueIndex"`
|
||||
Email string `gorm:"column:email;type:varchar(100);not null"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:now()"`
|
||||
|
||||
// Relationships
|
||||
Posts []*ModelPost `gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (ModelUser) TableName() string {
|
||||
return "public.users"
|
||||
}
|
||||
|
||||
type ModelPost struct {
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id;type:bigint;not null"`
|
||||
Title string `gorm:"column:title;type:varchar(200);not null"`
|
||||
Content string `gorm:"column:content;type:text"`
|
||||
|
||||
// Belongs-to relationship
|
||||
User *ModelUser `gorm:"foreignKey:UserID;references:ID"`
|
||||
}
|
||||
|
||||
func (ModelPost) TableName() string {
|
||||
return "public.posts"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Test files (ending in `_test.go`) are automatically excluded
|
||||
- The `gorm.Model` embedded struct is automatically recognized and skipped
|
||||
- Table names are derived from struct names if `TableName()` method is not present
|
||||
- Schema defaults to `public` if not specified in `TableName()`
|
||||
- Relationships are inferred from GORM relationship tags
|
||||
|
||||
## Limitations
|
||||
|
||||
- Complex relationship types (many-to-many with join tables) may need manual verification
|
||||
- Custom GORM types may not be fully supported
|
||||
- Some advanced GORM features may not be captured
|
||||
@@ -693,7 +693,7 @@ func (r *Reader) deriveTableName(structName string) string {
|
||||
|
||||
// parseColumn parses a struct field into a Column model
|
||||
// Returns the column and any inline reference information (e.g., "mainaccount(id_mainaccount)")
|
||||
func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, sequence uint) (*models.Column, string) {
|
||||
func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, sequence uint) (col *models.Column, ref string) {
|
||||
// Extract gorm tag
|
||||
gormTag := r.extractGormTag(tag)
|
||||
if gormTag == "" {
|
||||
@@ -756,20 +756,14 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
// - explicit "not null" tag means NOT NULL
|
||||
// - absence of "not null" tag with sql_types means nullable
|
||||
// - primitive types (string, int64, bool) default to NOT NULL unless explicitly nullable
|
||||
// Primary keys are always NOT NULL
|
||||
column.NotNull = false
|
||||
if _, hasNotNull := parts["not null"]; hasNotNull {
|
||||
column.NotNull = true
|
||||
} else {
|
||||
// If no explicit "not null" tag, check the Go type
|
||||
if r.isNullableGoType(fieldType) {
|
||||
// sql_types.SqlString, etc. are nullable by default
|
||||
column.NotNull = false
|
||||
} else {
|
||||
// Primitive types default to NOT NULL
|
||||
column.NotNull = false // Default to nullable unless explicitly set
|
||||
}
|
||||
// sql_types.SqlString, etc. are nullable by default
|
||||
column.NotNull = !r.isNullableGoType(fieldType)
|
||||
}
|
||||
|
||||
// Primary keys are always NOT NULL
|
||||
if column.IsPrimaryKey {
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
464
pkg/readers/gorm/reader_test.go
Normal file
464
pkg/readers/gorm/reader_test.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm", "simple.go"),
|
||||
}
|
||||
|
||||
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 schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if len(table.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(table.Columns))
|
||||
}
|
||||
|
||||
// Verify id column - primary key should be NOT NULL
|
||||
idCol, exists := table.Columns["id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'id' not found")
|
||||
}
|
||||
if !idCol.IsPrimaryKey {
|
||||
t.Error("Column 'id' should be primary key")
|
||||
}
|
||||
if !idCol.AutoIncrement {
|
||||
t.Error("Column 'id' should be auto-increment")
|
||||
}
|
||||
if !idCol.NotNull {
|
||||
t.Error("Column 'id' should be NOT NULL (primary keys are always NOT NULL)")
|
||||
}
|
||||
if idCol.Type != "bigint" {
|
||||
t.Errorf("Expected id type 'bigint', got '%s'", idCol.Type)
|
||||
}
|
||||
|
||||
// Verify email column - explicit "not null" tag should be NOT NULL
|
||||
emailCol, exists := table.Columns["email"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'email' not found")
|
||||
}
|
||||
if !emailCol.NotNull {
|
||||
t.Error("Column 'email' should be NOT NULL (explicit 'not null' tag)")
|
||||
}
|
||||
if emailCol.Type != "varchar" || emailCol.Length != 255 {
|
||||
t.Errorf("Expected email type 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
}
|
||||
|
||||
// Verify name column - primitive string type should be NOT NULL by default
|
||||
nameCol, exists := table.Columns["name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'name' not found")
|
||||
}
|
||||
if !nameCol.NotNull {
|
||||
t.Error("Column 'name' should be NOT NULL (primitive string type defaults to NOT NULL)")
|
||||
}
|
||||
if nameCol.Type != "text" {
|
||||
t.Errorf("Expected name type 'text', got '%s'", nameCol.Type)
|
||||
}
|
||||
|
||||
// Verify age column - pointer type should be nullable (NOT NULL = false)
|
||||
ageCol, exists := table.Columns["age"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'age' not found")
|
||||
}
|
||||
if ageCol.NotNull {
|
||||
t.Error("Column 'age' should be nullable (pointer type *int)")
|
||||
}
|
||||
if ageCol.Type != "integer" {
|
||||
t.Errorf("Expected age type 'integer', got '%s'", ageCol.Type)
|
||||
}
|
||||
|
||||
// Verify is_active column - primitive bool type should be NOT NULL by default
|
||||
isActiveCol, exists := table.Columns["is_active"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'is_active' not found")
|
||||
}
|
||||
if !isActiveCol.NotNull {
|
||||
t.Error("Column 'is_active' should be NOT NULL (primitive bool type defaults to NOT NULL)")
|
||||
}
|
||||
if isActiveCol.Type != "boolean" {
|
||||
t.Errorf("Expected is_active type 'boolean', got '%s'", isActiveCol.Type)
|
||||
}
|
||||
|
||||
// Verify created_at column - time.Time should be NOT NULL by default
|
||||
createdAtCol, exists := table.Columns["created_at"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'created_at' not found")
|
||||
}
|
||||
if !createdAtCol.NotNull {
|
||||
t.Error("Column 'created_at' should be NOT NULL (time.Time is NOT NULL by default)")
|
||||
}
|
||||
if createdAtCol.Type != "timestamp" {
|
||||
t.Errorf("Expected created_at type 'timestamp', got '%s'", createdAtCol.Type)
|
||||
}
|
||||
if createdAtCol.Default != "now()" {
|
||||
t.Errorf("Expected created_at default 'now()', got '%v'", createdAtCol.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm", "complex.go"),
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Verify schema
|
||||
if len(db.Schemas) != 1 {
|
||||
t.Fatalf("Expected 1 schema, got %d", len(db.Schemas))
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
// Verify tables
|
||||
if len(schema.Tables) != 3 {
|
||||
t.Fatalf("Expected 3 tables, got %d", len(schema.Tables))
|
||||
}
|
||||
|
||||
// Find tables
|
||||
var usersTable, postsTable, commentsTable *models.Table
|
||||
for _, table := range schema.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 - test NOT NULL logic for various field types
|
||||
if len(usersTable.Columns) != 10 {
|
||||
t.Errorf("Expected 10 columns in users table, got %d", len(usersTable.Columns))
|
||||
}
|
||||
|
||||
// username - NOT NULL (explicit tag)
|
||||
usernameCol, exists := usersTable.Columns["username"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'username' not found")
|
||||
}
|
||||
if !usernameCol.NotNull {
|
||||
t.Error("Column 'username' should be NOT NULL (explicit 'not null' tag)")
|
||||
}
|
||||
|
||||
// first_name - nullable (pointer type)
|
||||
firstNameCol, exists := usersTable.Columns["first_name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'first_name' not found")
|
||||
}
|
||||
if firstNameCol.NotNull {
|
||||
t.Error("Column 'first_name' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// last_name - nullable (pointer type)
|
||||
lastNameCol, exists := usersTable.Columns["last_name"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'last_name' not found")
|
||||
}
|
||||
if lastNameCol.NotNull {
|
||||
t.Error("Column 'last_name' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// bio - nullable (pointer type)
|
||||
bioCol, exists := usersTable.Columns["bio"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'bio' not found")
|
||||
}
|
||||
if bioCol.NotNull {
|
||||
t.Error("Column 'bio' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// is_active - NOT NULL (primitive bool)
|
||||
isActiveCol, exists := usersTable.Columns["is_active"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'is_active' not found")
|
||||
}
|
||||
if !isActiveCol.NotNull {
|
||||
t.Error("Column 'is_active' should be NOT NULL (primitive bool type)")
|
||||
}
|
||||
|
||||
// Verify users table indexes
|
||||
if len(usersTable.Indexes) < 1 {
|
||||
t.Error("Expected at least 1 index on users table")
|
||||
}
|
||||
|
||||
// Verify posts table
|
||||
if len(postsTable.Columns) != 11 {
|
||||
t.Errorf("Expected 11 columns in posts table, got %d", len(postsTable.Columns))
|
||||
}
|
||||
|
||||
// excerpt - nullable (pointer type)
|
||||
excerptCol, exists := postsTable.Columns["excerpt"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'excerpt' not found")
|
||||
}
|
||||
if excerptCol.NotNull {
|
||||
t.Error("Column 'excerpt' should be nullable (pointer type *string)")
|
||||
}
|
||||
|
||||
// published - NOT NULL (primitive bool with default)
|
||||
publishedCol, exists := postsTable.Columns["published"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'published' not found")
|
||||
}
|
||||
if !publishedCol.NotNull {
|
||||
t.Error("Column 'published' should be NOT NULL (primitive bool type)")
|
||||
}
|
||||
if publishedCol.Default != "false" {
|
||||
t.Errorf("Expected published default 'false', got '%v'", publishedCol.Default)
|
||||
}
|
||||
|
||||
// published_at - nullable (pointer to time.Time)
|
||||
publishedAtCol, exists := postsTable.Columns["published_at"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'published_at' not found")
|
||||
}
|
||||
if publishedAtCol.NotNull {
|
||||
t.Error("Column 'published_at' should be nullable (pointer type *time.Time)")
|
||||
}
|
||||
|
||||
// view_count - NOT NULL (primitive int64 with default)
|
||||
viewCountCol, exists := postsTable.Columns["view_count"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'view_count' not found")
|
||||
}
|
||||
if !viewCountCol.NotNull {
|
||||
t.Error("Column 'view_count' should be NOT NULL (primitive int64 type)")
|
||||
}
|
||||
if viewCountCol.Default != "0" {
|
||||
t.Errorf("Expected view_count default '0', got '%v'", viewCountCol.Default)
|
||||
}
|
||||
|
||||
// Verify posts table indexes
|
||||
if len(postsTable.Indexes) < 1 {
|
||||
t.Error("Expected at least 1 index on posts table")
|
||||
}
|
||||
|
||||
// Verify comments table
|
||||
if len(commentsTable.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns in comments table, got %d", len(commentsTable.Columns))
|
||||
}
|
||||
|
||||
// user_id - nullable (pointer type)
|
||||
userIDCol, exists := commentsTable.Columns["user_id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'user_id' not found in comments table")
|
||||
}
|
||||
if userIDCol.NotNull {
|
||||
t.Error("Column 'user_id' should be nullable (pointer type *int64)")
|
||||
}
|
||||
|
||||
// post_id - NOT NULL (explicit tag)
|
||||
postIDCol, exists := commentsTable.Columns["post_id"]
|
||||
if !exists {
|
||||
t.Fatal("Column 'post_id' not found in comments table")
|
||||
}
|
||||
if !postIDCol.NotNull {
|
||||
t.Error("Column 'post_id' should be NOT NULL (explicit 'not null' tag)")
|
||||
}
|
||||
|
||||
// Verify foreign key constraints
|
||||
if len(postsTable.Constraints) == 0 {
|
||||
t.Error("Expected at least one constraint on posts table")
|
||||
}
|
||||
|
||||
// Find FK constraint to users
|
||||
var fkPostsUser *models.Constraint
|
||||
for _, c := range postsTable.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint && c.ReferencedTable == "users" {
|
||||
fkPostsUser = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fkPostsUser != nil {
|
||||
if fkPostsUser.OnDelete != "CASCADE" {
|
||||
t.Errorf("Expected ON DELETE CASCADE for posts->users FK, got '%s'", fkPostsUser.OnDelete)
|
||||
}
|
||||
if fkPostsUser.OnUpdate != "CASCADE" {
|
||||
t.Errorf("Expected ON UPDATE CASCADE for posts->users FK, got '%s'", fkPostsUser.OnUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify comments table constraints
|
||||
if len(commentsTable.Constraints) == 0 {
|
||||
t.Error("Expected at least one constraint on comments table")
|
||||
}
|
||||
|
||||
// Find FK constraints
|
||||
var fkCommentsPost, fkCommentsUser *models.Constraint
|
||||
for _, c := range commentsTable.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint {
|
||||
if c.ReferencedTable == "posts" {
|
||||
fkCommentsPost = c
|
||||
} else if c.ReferencedTable == "users" {
|
||||
fkCommentsUser = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fkCommentsPost != nil {
|
||||
if fkCommentsPost.OnDelete != "CASCADE" {
|
||||
t.Errorf("Expected ON DELETE CASCADE for comments->posts FK, got '%s'", fkCommentsPost.OnDelete)
|
||||
}
|
||||
}
|
||||
|
||||
if fkCommentsUser != nil {
|
||||
if fkCommentsUser.OnDelete != "SET NULL" {
|
||||
t.Errorf("Expected ON DELETE SET NULL for comments->users FK, got '%s'", fkCommentsUser.OnDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm", "simple.go"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSchema() error = %v", err)
|
||||
}
|
||||
|
||||
if schema == nil {
|
||||
t.Fatal("ReadSchema() returned nil schema")
|
||||
}
|
||||
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
if len(schema.Tables) != 1 {
|
||||
t.Errorf("Expected 1 table, got %d", len(schema.Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm", "simple.go"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
table, err := reader.ReadTable()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadTable() error = %v", err)
|
||||
}
|
||||
|
||||
if table == nil {
|
||||
t.Fatal("ReadTable() returned nil table")
|
||||
}
|
||||
|
||||
if table.Name != "users" {
|
||||
t.Errorf("Expected table name 'users', got '%s'", table.Name)
|
||||
}
|
||||
|
||||
if len(table.Columns) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(table.Columns))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_Directory(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm"),
|
||||
}
|
||||
|
||||
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 read both simple.go and complex.go
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("Expected at least one schema")
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
// Should have at least 3 tables from complex.go (users, posts, comments)
|
||||
// plus 1 from simple.go (users) - but same table name, so may be overwritten
|
||||
if len(schema.Tables) < 3 {
|
||||
t.Errorf("Expected at least 3 tables, got %d", len(schema.Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_InvalidPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/file.go",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid file path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_EmptyPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty file path")
|
||||
}
|
||||
}
|
||||
203
pkg/readers/graphql/README.md
Normal file
203
pkg/readers/graphql/README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# GraphQL Schema Reader
|
||||
|
||||
The GraphQL reader parses GraphQL Schema Definition Language (SDL) files and converts them into RelSpec's internal database model.
|
||||
|
||||
## Features
|
||||
|
||||
- **Standard GraphQL SDL** support (generic, non-framework-specific)
|
||||
- **Type to Table mapping**: GraphQL types become database tables
|
||||
- **Field to Column mapping**: GraphQL fields become table columns
|
||||
- **Enum support**: GraphQL enums are preserved
|
||||
- **Custom scalars**: DateTime, JSON, Date automatically mapped to appropriate SQL types
|
||||
- **Implicit relationships**: Detects relationships from field types
|
||||
- **Many-to-many support**: Creates junction tables for bidirectional array relationships
|
||||
- **Configurable ID mapping**: Choose between bigint (default) or UUID for ID fields
|
||||
|
||||
## Supported GraphQL Features
|
||||
|
||||
### Built-in Scalars
|
||||
- `ID` → bigint (default) or uuid (configurable)
|
||||
- `String` → text
|
||||
- `Int` → integer
|
||||
- `Float` → double precision
|
||||
- `Boolean` → boolean
|
||||
|
||||
### Custom Scalars
|
||||
- `DateTime` → timestamp
|
||||
- `JSON` → jsonb
|
||||
- `Date` → date
|
||||
- `Time` → time
|
||||
- `Decimal` → numeric
|
||||
|
||||
Additional custom scalars can be mapped via metadata.
|
||||
|
||||
### Relationships
|
||||
|
||||
Relationships are inferred from field types:
|
||||
|
||||
```graphql
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
author: User! # Many-to-one (creates authorId FK column, NOT NULL)
|
||||
reviewer: User # Many-to-one nullable (creates reviewerId FK column, NULL)
|
||||
tags: [Tag!]! # One-to-many or many-to-many (depending on reverse)
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
posts: [Post!]! # Reverse of Post.author (no FK created)
|
||||
}
|
||||
|
||||
type Tag {
|
||||
id: ID!
|
||||
posts: [Post!]! # Many-to-many with Post (creates PostTag junction table)
|
||||
}
|
||||
```
|
||||
|
||||
**Relationship Detection Rules:**
|
||||
- Single type reference (`user: User`) → Creates FK column (e.g., `userId`)
|
||||
- Array type reference (`posts: [Post!]!`) → One-to-many reverse (no FK on this table)
|
||||
- Bidirectional arrays → Many-to-many (creates junction table)
|
||||
|
||||
### Enums
|
||||
|
||||
```graphql
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
GUEST
|
||||
}
|
||||
|
||||
type User {
|
||||
role: Role!
|
||||
}
|
||||
```
|
||||
|
||||
Enums are preserved in the schema and can be used as column types.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
)
|
||||
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "schema.graphql",
|
||||
}
|
||||
|
||||
reader := graphql.NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
```
|
||||
|
||||
### With UUID ID Type
|
||||
|
||||
```go
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "schema.graphql",
|
||||
Metadata: map[string]interface{}{
|
||||
"idType": "uuid", // Map ID scalar to uuid instead of bigint
|
||||
},
|
||||
}
|
||||
|
||||
reader := graphql.NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
```
|
||||
|
||||
### With Per-Type ID Mapping
|
||||
|
||||
```go
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "schema.graphql",
|
||||
Metadata: map[string]interface{}{
|
||||
"typeIdMappings": map[string]string{
|
||||
"User": "uuid", // User.id → uuid
|
||||
"Post": "bigint", // Post.id → bigint
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Scalar Mappings
|
||||
|
||||
```go
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "schema.graphql",
|
||||
Metadata: map[string]interface{}{
|
||||
"customScalarMappings": map[string]string{
|
||||
"Upload": "bytea",
|
||||
"Decimal": "numeric(10,2)",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# Convert GraphQL to JSON
|
||||
relspec convert --from graphql --from-path schema.graphql \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# Convert GraphQL to GORM models
|
||||
relspec convert --from graphql --from-path schema.graphql \
|
||||
--to gorm --to-path models/ --package models
|
||||
|
||||
# Convert GraphQL to PostgreSQL SQL
|
||||
relspec convert --from graphql --from-path schema.graphql \
|
||||
--to pgsql --to-path schema.sql
|
||||
```
|
||||
|
||||
## Metadata Options
|
||||
|
||||
| Option | Type | Description | Default |
|
||||
|--------|------|-------------|---------|
|
||||
| `idType` | string | Global ID type mapping ("bigint" or "uuid") | "bigint" |
|
||||
| `typeIdMappings` | map[string]string | Per-type ID mappings | {} |
|
||||
| `customScalarMappings` | map[string]string | Custom scalar to SQL type mappings | {} |
|
||||
| `schemaName` | string | Schema name for all tables | "public" |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only supports GraphQL SDL (Schema Definition Language), not queries or mutations
|
||||
- Directives are ignored (except for future extensibility)
|
||||
- Interfaces and Unions are not supported
|
||||
- GraphQL's concept of "schema" is different from database schemas; all types go into a single database schema (default: "public")
|
||||
|
||||
## Example
|
||||
|
||||
**Input** (`schema.graphql`):
|
||||
```graphql
|
||||
scalar DateTime
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
email: String!
|
||||
role: Role!
|
||||
createdAt: DateTime!
|
||||
posts: [Post!]!
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
content: String
|
||||
published: Boolean!
|
||||
author: User!
|
||||
}
|
||||
```
|
||||
|
||||
**Result**: Database with:
|
||||
- 2 tables: `User` and `Post`
|
||||
- `Post` table has `authorId` foreign key to `User.id`
|
||||
- `Role` enum with values: ADMIN, USER
|
||||
- Custom scalar `DateTime` mapped to `timestamp`
|
||||
279
pkg/readers/graphql/reader.go
Normal file
279
pkg/readers/graphql/reader.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
}
|
||||
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL reader")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return r.parseGraphQL(string(content))
|
||||
}
|
||||
|
||||
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 schemas found")
|
||||
}
|
||||
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found")
|
||||
}
|
||||
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
type parseContext struct {
|
||||
inType bool
|
||||
inEnum bool
|
||||
currentType string
|
||||
typeLines []string
|
||||
currentEnum string
|
||||
enumLines []string
|
||||
customScalars map[string]bool
|
||||
}
|
||||
|
||||
func (r *Reader) parseGraphQL(content string) (*models.Database, error) {
|
||||
dbName := "database"
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
dbName = name
|
||||
}
|
||||
}
|
||||
|
||||
db := models.InitDatabase(dbName)
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
ctx := &parseContext{
|
||||
customScalars: make(map[string]bool),
|
||||
}
|
||||
|
||||
// First pass: collect custom scalars and enums
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
scalarRegex := regexp.MustCompile(`^\s*scalar\s+(\w+)`)
|
||||
enumRegex := regexp.MustCompile(`^\s*enum\s+(\w+)\s*\{`)
|
||||
closingBraceRegex := regexp.MustCompile(`^\s*\}`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := scalarRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
ctx.customScalars[matches[1]] = true
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := enumRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
ctx.inEnum = true
|
||||
ctx.currentEnum = matches[1]
|
||||
ctx.enumLines = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
if closingBraceRegex.MatchString(trimmed) && ctx.inEnum {
|
||||
r.parseEnum(ctx.currentEnum, ctx.enumLines, schema)
|
||||
// Add enum name to custom scalars for type detection
|
||||
ctx.customScalars[ctx.currentEnum] = true
|
||||
ctx.inEnum = false
|
||||
ctx.currentEnum = ""
|
||||
ctx.enumLines = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if ctx.inEnum {
|
||||
ctx.enumLines = append(ctx.enumLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
// Second pass: parse types
|
||||
scanner = bufio.NewScanner(strings.NewReader(content))
|
||||
typeRegex := regexp.MustCompile(`^\s*type\s+(\w+)\s*\{`)
|
||||
ctx.inType = false
|
||||
ctx.inEnum = false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := typeRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
ctx.inType = true
|
||||
ctx.currentType = matches[1]
|
||||
ctx.typeLines = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
if closingBraceRegex.MatchString(trimmed) && ctx.inType {
|
||||
if err := r.parseType(ctx.currentType, ctx.typeLines, schema, ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse type %s: %w", ctx.currentType, err)
|
||||
}
|
||||
ctx.inType = false
|
||||
ctx.currentType = ""
|
||||
ctx.typeLines = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if ctx.inType {
|
||||
ctx.typeLines = append(ctx.typeLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
// Third pass: detect and create relationships
|
||||
if err := r.detectAndCreateRelationships(schema, ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to create relationships: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
type fieldInfo struct {
|
||||
name string
|
||||
typeName string
|
||||
isArray bool
|
||||
isNullable bool
|
||||
innerNullable bool
|
||||
}
|
||||
|
||||
func (r *Reader) parseType(typeName string, lines []string, schema *models.Schema, ctx *parseContext) error {
|
||||
table := models.InitTable(typeName, schema.Name)
|
||||
table.Metadata = make(map[string]any)
|
||||
|
||||
// Store field info for relationship detection
|
||||
relationFields := make(map[string]*fieldInfo)
|
||||
|
||||
fieldRegex := regexp.MustCompile(`^\s*(\w+)\s*:\s*(\[)?(\w+)(!)?(\])?(!)?\s*`)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := fieldRegex.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := matches[1]
|
||||
hasOpenBracket := matches[2] == "["
|
||||
baseType := matches[3]
|
||||
innerNonNull := matches[4] == "!"
|
||||
hasCloseBracket := matches[5] == "]"
|
||||
outerNonNull := matches[6] == "!"
|
||||
|
||||
isArray := hasOpenBracket && hasCloseBracket
|
||||
|
||||
// Determine if this is a scalar or a relation
|
||||
if r.isScalarType(baseType, ctx) {
|
||||
// This is a scalar field
|
||||
column := models.InitColumn(fieldName, table.Name, schema.Name)
|
||||
column.Type = r.graphQLTypeToSQL(baseType, fieldName, typeName)
|
||||
|
||||
if isArray {
|
||||
// Array of scalars: use array type
|
||||
column.Type += "[]"
|
||||
column.NotNull = outerNonNull
|
||||
} else {
|
||||
column.NotNull = !isArray && innerNonNull
|
||||
}
|
||||
|
||||
// Check if this is a primary key (convention: field named "id")
|
||||
if fieldName == "id" {
|
||||
column.IsPrimaryKey = true
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
|
||||
table.Columns[fieldName] = column
|
||||
} else {
|
||||
// This is a relation field - store for later processing
|
||||
relationFields[fieldName] = &fieldInfo{
|
||||
name: fieldName,
|
||||
typeName: baseType,
|
||||
isArray: isArray,
|
||||
isNullable: !innerNonNull && !isArray,
|
||||
innerNullable: !innerNonNull && isArray,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store relation fields in table metadata for relationship detection
|
||||
if len(relationFields) > 0 {
|
||||
table.Metadata["relationFields"] = relationFields
|
||||
}
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) parseEnum(enumName string, lines []string, schema *models.Schema) {
|
||||
enum := &models.Enum{
|
||||
Name: enumName,
|
||||
Schema: schema.Name,
|
||||
Values: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
// Enum values are simple identifiers
|
||||
enum.Values = append(enum.Values, trimmed)
|
||||
}
|
||||
|
||||
schema.Enums = append(schema.Enums, enum)
|
||||
}
|
||||
362
pkg/readers/graphql/reader_test.go
Normal file
362
pkg/readers/graphql/reader_test.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "simple.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("Expected at least one schema")
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
if len(schema.Tables) != 1 {
|
||||
t.Fatalf("Expected 1 table, got %d", len(schema.Tables))
|
||||
}
|
||||
|
||||
userTable := schema.Tables[0]
|
||||
if userTable.Name != "User" {
|
||||
t.Errorf("Expected table name 'User', got '%s'", userTable.Name)
|
||||
}
|
||||
|
||||
// Verify columns
|
||||
expectedColumns := map[string]struct {
|
||||
sqlType string
|
||||
notNull bool
|
||||
isPK bool
|
||||
}{
|
||||
"id": {"bigint", true, true},
|
||||
"email": {"text", true, false},
|
||||
"name": {"text", false, false},
|
||||
"age": {"integer", false, false},
|
||||
"active": {"boolean", true, false},
|
||||
}
|
||||
|
||||
if len(userTable.Columns) != len(expectedColumns) {
|
||||
t.Fatalf("Expected %d columns, got %d", len(expectedColumns), len(userTable.Columns))
|
||||
}
|
||||
|
||||
for colName, expected := range expectedColumns {
|
||||
col, exists := userTable.Columns[colName]
|
||||
if !exists {
|
||||
t.Errorf("Expected column '%s' not found", colName)
|
||||
continue
|
||||
}
|
||||
|
||||
if col.Type != expected.sqlType {
|
||||
t.Errorf("Column '%s': expected type '%s', got '%s'", colName, expected.sqlType, col.Type)
|
||||
}
|
||||
|
||||
if col.NotNull != expected.notNull {
|
||||
t.Errorf("Column '%s': expected NotNull=%v, got %v", colName, expected.notNull, col.NotNull)
|
||||
}
|
||||
|
||||
if col.IsPrimaryKey != expected.isPK {
|
||||
t.Errorf("Column '%s': expected IsPrimaryKey=%v, got %v", colName, expected.isPK, col.IsPrimaryKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_WithRelations(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "relations.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
|
||||
if len(schema.Tables) != 2 {
|
||||
t.Fatalf("Expected 2 tables, got %d", len(schema.Tables))
|
||||
}
|
||||
|
||||
// Find Post table (should have FK to User)
|
||||
var postTable *models.Table
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == "Post" {
|
||||
postTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if postTable == nil {
|
||||
t.Fatal("Post table not found")
|
||||
}
|
||||
|
||||
// Verify authorId FK column was created
|
||||
authorIdCol, exists := postTable.Columns["authorId"]
|
||||
if !exists {
|
||||
t.Fatal("Expected 'authorId' FK column not found in Post table")
|
||||
}
|
||||
|
||||
if authorIdCol.Type != "bigint" {
|
||||
t.Errorf("Expected authorId type 'bigint', got '%s'", authorIdCol.Type)
|
||||
}
|
||||
|
||||
if !authorIdCol.NotNull {
|
||||
t.Error("Expected authorId to be NOT NULL")
|
||||
}
|
||||
|
||||
// Verify FK constraint
|
||||
fkConstraintFound := false
|
||||
for _, constraint := range postTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
if constraint.ReferencedTable == "User" && len(constraint.Columns) > 0 && constraint.Columns[0] == "authorId" {
|
||||
fkConstraintFound = true
|
||||
if constraint.OnDelete != "CASCADE" {
|
||||
t.Errorf("Expected OnDelete CASCADE, got %s", constraint.OnDelete)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fkConstraintFound {
|
||||
t.Error("Foreign key constraint from Post to User not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_WithEnums(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "enums.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
|
||||
if len(schema.Enums) != 1 {
|
||||
t.Fatalf("Expected 1 enum, got %d", len(schema.Enums))
|
||||
}
|
||||
|
||||
roleEnum := schema.Enums[0]
|
||||
if roleEnum.Name != "Role" {
|
||||
t.Errorf("Expected enum name 'Role', got '%s'", roleEnum.Name)
|
||||
}
|
||||
|
||||
expectedValues := []string{"ADMIN", "USER", "GUEST"}
|
||||
if len(roleEnum.Values) != len(expectedValues) {
|
||||
t.Fatalf("Expected %d enum values, got %d", len(expectedValues), len(roleEnum.Values))
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if roleEnum.Values[i] != expected {
|
||||
t.Errorf("Expected enum value '%s' at index %d, got '%s'", expected, i, roleEnum.Values[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify role column in User table
|
||||
userTable := schema.Tables[0]
|
||||
roleCol, exists := userTable.Columns["role"]
|
||||
if !exists {
|
||||
t.Fatal("Expected 'role' column not found")
|
||||
}
|
||||
|
||||
if roleCol.Type != "Role" {
|
||||
t.Errorf("Expected role type 'Role', got '%s'", roleCol.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_CustomScalars(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "custom_scalars.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
userTable := schema.Tables[0]
|
||||
|
||||
// Verify custom scalar mappings
|
||||
expectedTypes := map[string]string{
|
||||
"createdAt": "timestamp",
|
||||
"metadata": "jsonb",
|
||||
"birthDate": "date",
|
||||
}
|
||||
|
||||
for colName, expectedType := range expectedTypes {
|
||||
col, exists := userTable.Columns[colName]
|
||||
if !exists {
|
||||
t.Errorf("Expected column '%s' not found", colName)
|
||||
continue
|
||||
}
|
||||
|
||||
if col.Type != expectedType {
|
||||
t.Errorf("Column '%s': expected type '%s', got '%s'", colName, expectedType, col.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_UUIDMetadata(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "simple.graphql"),
|
||||
Metadata: map[string]interface{}{
|
||||
"idType": "uuid",
|
||||
},
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
userTable := schema.Tables[0]
|
||||
|
||||
idCol, exists := userTable.Columns["id"]
|
||||
if !exists {
|
||||
t.Fatal("Expected 'id' column not found")
|
||||
}
|
||||
|
||||
if idCol.Type != "uuid" {
|
||||
t.Errorf("Expected id type 'uuid' with metadata, got '%s'", idCol.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "complex.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
schema := db.Schemas[0]
|
||||
|
||||
// Should have 5 tables: User, Profile, Post, Tag, and PostTag (join table)
|
||||
expectedTableCount := 5
|
||||
if len(schema.Tables) != expectedTableCount {
|
||||
t.Fatalf("Expected %d tables, got %d", expectedTableCount, len(schema.Tables))
|
||||
}
|
||||
|
||||
// Verify PostTag join table exists (many-to-many between Post and Tag)
|
||||
var joinTable *models.Table
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == "PostTag" {
|
||||
joinTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if joinTable == nil {
|
||||
t.Fatal("Expected PostTag join table not found")
|
||||
}
|
||||
|
||||
// Verify join table has both FK columns
|
||||
if _, exists := joinTable.Columns["postId"]; !exists {
|
||||
t.Error("Expected 'postId' column in PostTag join table")
|
||||
}
|
||||
|
||||
if _, exists := joinTable.Columns["tagId"]; !exists {
|
||||
t.Error("Expected 'tagId' column in PostTag join table")
|
||||
}
|
||||
|
||||
// Verify composite primary key
|
||||
pkFound := false
|
||||
for _, constraint := range joinTable.Constraints {
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
if len(constraint.Columns) == 2 {
|
||||
pkFound = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !pkFound {
|
||||
t.Error("Expected composite primary key in PostTag join table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "simple.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
schema, err := reader.ReadSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSchema() error = %v", err)
|
||||
}
|
||||
|
||||
if schema.Name != "public" {
|
||||
t.Errorf("Expected schema name 'public', got '%s'", schema.Name)
|
||||
}
|
||||
|
||||
if len(schema.Tables) != 1 {
|
||||
t.Errorf("Expected 1 table, got %d", len(schema.Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "graphql", "simple.graphql"),
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
table, err := reader.ReadTable()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadTable() error = %v", err)
|
||||
}
|
||||
|
||||
if table.Name != "User" {
|
||||
t.Errorf("Expected table name 'User', got '%s'", table.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_InvalidPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/path.graphql",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_EmptyPath(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: "",
|
||||
}
|
||||
|
||||
reader := NewReader(opts)
|
||||
_, err := reader.ReadDatabase()
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
225
pkg/readers/graphql/relationships.go
Normal file
225
pkg/readers/graphql/relationships.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func (r *Reader) detectAndCreateRelationships(schema *models.Schema, ctx *parseContext) error {
|
||||
// Build table lookup map
|
||||
tableMap := make(map[string]*models.Table)
|
||||
for _, table := range schema.Tables {
|
||||
tableMap[table.Name] = table
|
||||
}
|
||||
|
||||
// Process each table's relation fields
|
||||
for _, table := range schema.Tables {
|
||||
relationFields, ok := table.Metadata["relationFields"].(map[string]*fieldInfo)
|
||||
if !ok || len(relationFields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for fieldName, fieldInfo := range relationFields {
|
||||
targetTable, exists := tableMap[fieldInfo.typeName]
|
||||
if !exists {
|
||||
// Referenced type doesn't exist - might be an interface/union, skip
|
||||
continue
|
||||
}
|
||||
|
||||
if fieldInfo.isArray {
|
||||
// This is a one-to-many or many-to-many reverse side
|
||||
// Check if target table has a reverse array field
|
||||
if r.hasReverseArrayField(targetTable, table.Name) {
|
||||
// Bidirectional array = many-to-many
|
||||
// Only create join table once (lexicographically first table creates it)
|
||||
if table.Name < targetTable.Name {
|
||||
if err := r.createManyToManyJoinTable(schema, table, targetTable, fieldName, tableMap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// For one-to-many, no action needed (FK is on the other table)
|
||||
} else {
|
||||
// This is a many-to-one or one-to-one
|
||||
// Create FK column on this table
|
||||
if err := r.createForeignKeyColumn(table, targetTable, fieldName, fieldInfo.isNullable, schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up metadata
|
||||
for _, table := range schema.Tables {
|
||||
delete(table.Metadata, "relationFields")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) hasReverseArrayField(table *models.Table, targetTypeName string) bool {
|
||||
relationFields, ok := table.Metadata["relationFields"].(map[string]*fieldInfo)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, fieldInfo := range relationFields {
|
||||
if fieldInfo.typeName == targetTypeName && fieldInfo.isArray {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Reader) createForeignKeyColumn(fromTable, toTable *models.Table, fieldName string, nullable bool, schema *models.Schema) error {
|
||||
// Get primary key from target table
|
||||
pkCol := toTable.GetPrimaryKey()
|
||||
if pkCol == nil {
|
||||
return fmt.Errorf("target table %s has no primary key for relationship", toTable.Name)
|
||||
}
|
||||
|
||||
// Create FK column name: {fieldName}Id
|
||||
fkColName := fieldName + "Id"
|
||||
|
||||
// Check if column already exists (shouldn't happen but be safe)
|
||||
if _, exists := fromTable.Columns[fkColName]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create FK column
|
||||
fkCol := models.InitColumn(fkColName, fromTable.Name, schema.Name)
|
||||
fkCol.Type = pkCol.Type
|
||||
fkCol.NotNull = !nullable
|
||||
|
||||
fromTable.Columns[fkColName] = fkCol
|
||||
|
||||
// Create FK constraint
|
||||
constraint := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", fromTable.Name, fieldName),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
constraint.Schema = schema.Name
|
||||
constraint.Table = fromTable.Name
|
||||
constraint.Columns = []string{fkColName}
|
||||
constraint.ReferencedSchema = schema.Name
|
||||
constraint.ReferencedTable = toTable.Name
|
||||
constraint.ReferencedColumns = []string{pkCol.Name}
|
||||
constraint.OnDelete = "CASCADE"
|
||||
constraint.OnUpdate = "RESTRICT"
|
||||
|
||||
fromTable.Constraints[constraint.Name] = constraint
|
||||
|
||||
// Create relationship
|
||||
relationship := models.InitRelationship(
|
||||
fmt.Sprintf("rel_%s_%s", fromTable.Name, fieldName),
|
||||
models.OneToMany,
|
||||
)
|
||||
relationship.FromTable = fromTable.Name
|
||||
relationship.FromSchema = schema.Name
|
||||
relationship.FromColumns = []string{fkColName}
|
||||
relationship.ToTable = toTable.Name
|
||||
relationship.ToSchema = schema.Name
|
||||
relationship.ToColumns = []string{pkCol.Name}
|
||||
relationship.ForeignKey = constraint.Name
|
||||
|
||||
fromTable.Relationships[relationship.Name] = relationship
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) createManyToManyJoinTable(schema *models.Schema, table1, table2 *models.Table, fieldName string, tableMap map[string]*models.Table) error {
|
||||
// Create join table name
|
||||
joinTableName := table1.Name + table2.Name
|
||||
|
||||
// Check if join table already exists
|
||||
if _, exists := tableMap[joinTableName]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get primary keys
|
||||
pk1 := table1.GetPrimaryKey()
|
||||
pk2 := table2.GetPrimaryKey()
|
||||
|
||||
if pk1 == nil || pk2 == nil {
|
||||
return fmt.Errorf("cannot create many-to-many: tables must have primary keys")
|
||||
}
|
||||
|
||||
// Create join table
|
||||
joinTable := models.InitTable(joinTableName, schema.Name)
|
||||
|
||||
// Create FK column for table1
|
||||
fkCol1Name := strings.ToLower(table1.Name) + "Id"
|
||||
fkCol1 := models.InitColumn(fkCol1Name, joinTable.Name, schema.Name)
|
||||
fkCol1.Type = pk1.Type
|
||||
fkCol1.NotNull = true
|
||||
joinTable.Columns[fkCol1Name] = fkCol1
|
||||
|
||||
// Create FK column for table2
|
||||
fkCol2Name := strings.ToLower(table2.Name) + "Id"
|
||||
fkCol2 := models.InitColumn(fkCol2Name, joinTable.Name, schema.Name)
|
||||
fkCol2.Type = pk2.Type
|
||||
fkCol2.NotNull = true
|
||||
joinTable.Columns[fkCol2Name] = fkCol2
|
||||
|
||||
// Create composite primary key
|
||||
pkConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("pk_%s", joinTableName),
|
||||
models.PrimaryKeyConstraint,
|
||||
)
|
||||
pkConstraint.Schema = schema.Name
|
||||
pkConstraint.Table = joinTable.Name
|
||||
pkConstraint.Columns = []string{fkCol1Name, fkCol2Name}
|
||||
joinTable.Constraints[pkConstraint.Name] = pkConstraint
|
||||
|
||||
// Create FK constraint to table1
|
||||
fk1 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, table1.Name),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk1.Schema = schema.Name
|
||||
fk1.Table = joinTable.Name
|
||||
fk1.Columns = []string{fkCol1Name}
|
||||
fk1.ReferencedSchema = schema.Name
|
||||
fk1.ReferencedTable = table1.Name
|
||||
fk1.ReferencedColumns = []string{pk1.Name}
|
||||
fk1.OnDelete = "CASCADE"
|
||||
fk1.OnUpdate = "RESTRICT"
|
||||
joinTable.Constraints[fk1.Name] = fk1
|
||||
|
||||
// Create FK constraint to table2
|
||||
fk2 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, table2.Name),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk2.Schema = schema.Name
|
||||
fk2.Table = joinTable.Name
|
||||
fk2.Columns = []string{fkCol2Name}
|
||||
fk2.ReferencedSchema = schema.Name
|
||||
fk2.ReferencedTable = table2.Name
|
||||
fk2.ReferencedColumns = []string{pk2.Name}
|
||||
fk2.OnDelete = "CASCADE"
|
||||
fk2.OnUpdate = "RESTRICT"
|
||||
joinTable.Constraints[fk2.Name] = fk2
|
||||
|
||||
// Create relationships
|
||||
rel1 := models.InitRelationship(
|
||||
fmt.Sprintf("rel_%s_%s_%s", joinTableName, table1.Name, table2.Name),
|
||||
models.ManyToMany,
|
||||
)
|
||||
rel1.FromTable = table1.Name
|
||||
rel1.FromSchema = schema.Name
|
||||
rel1.ToTable = table2.Name
|
||||
rel1.ToSchema = schema.Name
|
||||
rel1.ThroughTable = joinTableName
|
||||
rel1.ThroughSchema = schema.Name
|
||||
joinTable.Relationships[rel1.Name] = rel1
|
||||
|
||||
// Add join table to schema
|
||||
schema.Tables = append(schema.Tables, joinTable)
|
||||
tableMap[joinTableName] = joinTable
|
||||
|
||||
return nil
|
||||
}
|
||||
97
pkg/readers/graphql/type_mapping.go
Normal file
97
pkg/readers/graphql/type_mapping.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package graphql
|
||||
|
||||
func (r *Reader) isScalarType(typeName string, ctx *parseContext) bool {
|
||||
// Built-in GraphQL scalars
|
||||
builtInScalars := map[string]bool{
|
||||
"ID": true,
|
||||
"String": true,
|
||||
"Int": true,
|
||||
"Float": true,
|
||||
"Boolean": true,
|
||||
}
|
||||
|
||||
if builtInScalars[typeName] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Custom scalars declared in the schema
|
||||
if ctx.customScalars[typeName] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Common custom scalars (even if not declared)
|
||||
commonCustomScalars := map[string]bool{
|
||||
"DateTime": true,
|
||||
"JSON": true,
|
||||
"Date": true,
|
||||
"Time": true,
|
||||
"Upload": true,
|
||||
"Decimal": true,
|
||||
}
|
||||
|
||||
return commonCustomScalars[typeName]
|
||||
}
|
||||
|
||||
func (r *Reader) graphQLTypeToSQL(gqlType string, fieldName string, typeName string) string {
|
||||
// Check for ID type with configurable mapping
|
||||
if gqlType == "ID" {
|
||||
// Check metadata for ID type preference
|
||||
if r.options.Metadata != nil {
|
||||
// Global idType setting
|
||||
if idType, ok := r.options.Metadata["idType"].(string); ok {
|
||||
if idType == "uuid" {
|
||||
return "uuid"
|
||||
}
|
||||
}
|
||||
|
||||
// Per-type ID mapping
|
||||
if typeIdMappings, ok := r.options.Metadata["typeIdMappings"].(map[string]string); ok {
|
||||
if idType, ok := typeIdMappings[typeName]; ok {
|
||||
if idType == "uuid" {
|
||||
return "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "bigint" // Default
|
||||
}
|
||||
|
||||
// Custom scalar mappings
|
||||
if r.options.Metadata != nil {
|
||||
if customMappings, ok := r.options.Metadata["customScalarMappings"].(map[string]string); ok {
|
||||
if sqlType, ok := customMappings[gqlType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in custom scalar mappings
|
||||
customScalars := map[string]string{
|
||||
"DateTime": "timestamp",
|
||||
"JSON": "jsonb",
|
||||
"Date": "date",
|
||||
"Time": "time",
|
||||
"Decimal": "numeric",
|
||||
"Upload": "bytea",
|
||||
}
|
||||
if sqlType, ok := customScalars[gqlType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// Standard scalar mappings
|
||||
typeMap := map[string]string{
|
||||
"String": "text",
|
||||
"Int": "integer",
|
||||
"Float": "double precision",
|
||||
"Boolean": "boolean",
|
||||
}
|
||||
|
||||
if sqlType, ok := typeMap[gqlType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// If not a known scalar, assume it's an enum or custom type
|
||||
// Return as-is (might be an enum)
|
||||
return gqlType
|
||||
}
|
||||
152
pkg/readers/json/README.md
Normal file
152
pkg/readers/json/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# JSON Reader
|
||||
|
||||
Reads database schema definitions from JSON files.
|
||||
|
||||
## Overview
|
||||
|
||||
The JSON Reader parses JSON files that define database schemas in RelSpec's canonical JSON format and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads RelSpec's standard JSON schema format
|
||||
- Supports complete schema representation including:
|
||||
- Databases and schemas
|
||||
- Tables, columns, and data types
|
||||
- Constraints (PK, FK, unique, check)
|
||||
- Indexes
|
||||
- Relationships
|
||||
- Views and sequences
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/schema.json",
|
||||
}
|
||||
|
||||
reader := json.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read JSON schema and convert to GORM models
|
||||
relspec --input json --in-file schema.json --output gorm --out-file models.go
|
||||
|
||||
# Convert JSON to PostgreSQL DDL
|
||||
relspec --input json --in-file database.json --output pgsql --out-file schema.sql
|
||||
|
||||
# Transform JSON to YAML
|
||||
relspec --input json --in-file schema.json --output yaml --out-file schema.yaml
|
||||
```
|
||||
|
||||
## Example JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"database_type": "postgresql",
|
||||
"schemas": [
|
||||
{
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"name": "users",
|
||||
"schema": "public",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"not_null": true,
|
||||
"is_primary_key": true,
|
||||
"auto_increment": true,
|
||||
"sequence": 1
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar",
|
||||
"length": 50,
|
||||
"not_null": true,
|
||||
"sequence": 2
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar",
|
||||
"length": 100,
|
||||
"not_null": true,
|
||||
"sequence": 3
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"pk_users": {
|
||||
"name": "pk_users",
|
||||
"type": "PRIMARY KEY",
|
||||
"columns": ["id"]
|
||||
},
|
||||
"uq_users_username": {
|
||||
"name": "uq_users_username",
|
||||
"type": "UNIQUE",
|
||||
"columns": ["username"]
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_users_email": {
|
||||
"name": "idx_users_email",
|
||||
"columns": ["email"],
|
||||
"unique": false,
|
||||
"type": "btree"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Structure
|
||||
|
||||
The JSON format follows RelSpec's internal model structure:
|
||||
|
||||
- `Database` - Top-level container
|
||||
- `name` - Database name
|
||||
- `database_type` - Database system (postgresql, mysql, etc.)
|
||||
- `schemas[]` - Array of schemas
|
||||
|
||||
- `Schema` - Schema/namespace
|
||||
- `name` - Schema name
|
||||
- `tables[]` - Array of tables
|
||||
- `views[]` - Array of views
|
||||
- `sequences[]` - Array of sequences
|
||||
|
||||
- `Table` - Table definition
|
||||
- `name` - Table name
|
||||
- `columns{}` - Map of columns
|
||||
- `constraints{}` - Map of constraints
|
||||
- `indexes{}` - Map of indexes
|
||||
- `relationships{}` - Map of relationships
|
||||
|
||||
## Notes
|
||||
|
||||
- This is RelSpec's native interchange format
|
||||
- Preserves complete schema information
|
||||
- Ideal for version control and schema documentation
|
||||
- Can be used as an intermediate format for transformations
|
||||
138
pkg/readers/pgsql/README.md
Normal file
138
pkg/readers/pgsql/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# PostgreSQL Reader
|
||||
|
||||
Reads schema information directly from a live PostgreSQL database.
|
||||
|
||||
## Overview
|
||||
|
||||
The PostgreSQL Reader connects to a PostgreSQL database and introspects its schema, extracting complete information about tables, columns, constraints, indexes, views, and sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- Direct database introspection
|
||||
- Extracts complete schema information including:
|
||||
- Tables and columns
|
||||
- Primary keys, foreign keys, unique constraints, check constraints
|
||||
- Indexes
|
||||
- Views
|
||||
- Sequences
|
||||
- Supports multiple schemas
|
||||
- Captures constraint actions (ON DELETE, ON UPDATE)
|
||||
- Derives relationships from foreign keys
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
ConnectionString: "postgres://user:password@localhost:5432/mydb?sslmode=disable",
|
||||
}
|
||||
|
||||
reader := pgsql.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Database: %s\n", db.Name)
|
||||
fmt.Printf("Schemas: %d\n", len(db.Schemas))
|
||||
for _, schema := range db.Schemas {
|
||||
fmt.Printf(" Schema: %s, Tables: %d\n", schema.Name, len(schema.Tables))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Inspect PostgreSQL database and export to JSON
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://user:password@localhost:5432/mydb" \
|
||||
--output json \
|
||||
--out-file schema.json
|
||||
|
||||
# Generate GORM models from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://user:password@localhost:5432/mydb" \
|
||||
--output gorm \
|
||||
--out-file models.go
|
||||
|
||||
# Export database structure to YAML
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb?sslmode=disable" \
|
||||
--output yaml \
|
||||
--out-file schema.yaml
|
||||
```
|
||||
|
||||
## Connection String Format
|
||||
|
||||
The reader uses PostgreSQL connection strings in the format:
|
||||
|
||||
```
|
||||
postgres://username:password@hostname:port/database?parameters
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
postgres://localhost/mydb
|
||||
postgres://user:pass@localhost:5432/mydb
|
||||
postgres://user@localhost/mydb?sslmode=disable
|
||||
postgres://user:pass@db.example.com:5432/production?sslmode=require
|
||||
```
|
||||
|
||||
## Extracted Information
|
||||
|
||||
### Tables
|
||||
- Table name and schema
|
||||
- Comments/descriptions
|
||||
- All columns with data types, nullable, defaults
|
||||
- Sequences
|
||||
|
||||
### Columns
|
||||
- Column name, data type, length/precision
|
||||
- NULL/NOT NULL constraints
|
||||
- Default values
|
||||
- Auto-increment information
|
||||
- Primary key designation
|
||||
|
||||
### Constraints
|
||||
- Primary keys
|
||||
- Foreign keys (with ON DELETE/UPDATE actions)
|
||||
- Unique constraints
|
||||
- Check constraints
|
||||
|
||||
### Indexes
|
||||
- Index name and type (btree, hash, gist, gin, etc.)
|
||||
- Columns in index
|
||||
- Unique/non-unique
|
||||
- Partial indexes
|
||||
|
||||
### Views
|
||||
- View definitions
|
||||
- Column information
|
||||
|
||||
### Sequences
|
||||
- Sequence properties
|
||||
- Associated tables
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires PostgreSQL connection permissions
|
||||
- Reads all non-system schemas (excludes pg_catalog, information_schema, pg_toast)
|
||||
- Captures PostgreSQL-specific data types
|
||||
- Automatically maps PostgreSQL types to canonical types
|
||||
- Preserves relationship metadata for downstream conversion
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go library: `github.com/jackc/pgx/v5`
|
||||
- Database user must have SELECT permissions on system catalogs
|
||||
103
pkg/readers/prisma/README.md
Normal file
103
pkg/readers/prisma/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Prisma Reader
|
||||
|
||||
Reads Prisma schema files and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Prisma Reader parses `.prisma` schema files that define database models using Prisma's schema language and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses Prisma schema syntax
|
||||
- Extracts models, fields, and relationships
|
||||
- Supports Prisma attributes and directives
|
||||
- Handles enums and composite types
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/schema.prisma",
|
||||
}
|
||||
|
||||
reader := prisma.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read Prisma schema and convert to JSON
|
||||
relspec --input prisma --in-file schema.prisma --output json --out-file schema.json
|
||||
|
||||
# Convert Prisma to GORM models
|
||||
relspec --input prisma --in-file schema.prisma --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Example Prisma Schema
|
||||
|
||||
```prisma
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(50)
|
||||
email String @db.VarChar(100)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
posts Post[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
title String @db.VarChar(200)
|
||||
content String @db.Text
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("posts")
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Prisma Attributes
|
||||
|
||||
- `@id` - Primary key
|
||||
- `@unique` - Unique constraint
|
||||
- `@default` - Default value
|
||||
- `@map` - Column name mapping
|
||||
- `@@map` - Table name mapping
|
||||
- `@relation` - Relationship definition
|
||||
- `@db.*` - Database-specific type annotations
|
||||
|
||||
## Notes
|
||||
|
||||
- Extracts datasource provider information
|
||||
- Supports `@@map` for custom table names
|
||||
- Handles Prisma-specific types and converts them to standard SQL types
|
||||
815
pkg/readers/prisma/reader.go
Normal file
815
pkg/readers/prisma/reader.go
Normal file
@@ -0,0 +1,815 @@
|
||||
package prisma
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for Prisma schema format
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
}
|
||||
|
||||
// NewReader creates a new Prisma reader with the given options
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads and parses Prisma schema input, returning a Database model
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma reader")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return r.parsePrisma(string(content))
|
||||
}
|
||||
|
||||
// ReadSchema reads and parses Prisma schema input, returning a Schema model
|
||||
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 schemas found in Prisma schema")
|
||||
}
|
||||
|
||||
// Return the first schema
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable reads and parses Prisma schema input, returning a Table model
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in Prisma schema")
|
||||
}
|
||||
|
||||
// Return the first table
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// parsePrisma parses Prisma schema content and returns a Database model
|
||||
func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
db.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// Default schema for Prisma (doesn't have explicit schema concept in most cases)
|
||||
schema := models.InitSchema("public")
|
||||
schema.Enums = make([]*models.Enum, 0)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
// State tracking
|
||||
var currentBlock string // "datasource", "generator", "model", "enum"
|
||||
var currentTable *models.Table
|
||||
var currentEnum *models.Enum
|
||||
var blockContent []string
|
||||
|
||||
// Regex patterns
|
||||
datasourceRegex := regexp.MustCompile(`^datasource\s+\w+\s*{`)
|
||||
generatorRegex := regexp.MustCompile(`^generator\s+\w+\s*{`)
|
||||
modelRegex := regexp.MustCompile(`^model\s+(\w+)\s*{`)
|
||||
enumRegex := regexp.MustCompile(`^enum\s+(\w+)\s*{`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for block start
|
||||
if matches := datasourceRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentBlock = "datasource"
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := generatorRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentBlock = "generator"
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := modelRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentBlock = "model"
|
||||
tableName := matches[1]
|
||||
currentTable = models.InitTable(tableName, "public")
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := enumRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentBlock = "enum"
|
||||
enumName := matches[1]
|
||||
currentEnum = &models.Enum{
|
||||
Name: enumName,
|
||||
Schema: "public",
|
||||
Values: make([]string, 0),
|
||||
}
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for block end
|
||||
if trimmed == "}" {
|
||||
switch currentBlock {
|
||||
case "datasource":
|
||||
r.parseDatasource(blockContent, db)
|
||||
case "generator":
|
||||
// We don't need to do anything with generator blocks
|
||||
case "model":
|
||||
if currentTable != nil {
|
||||
r.parseModelFields(blockContent, currentTable)
|
||||
schema.Tables = append(schema.Tables, currentTable)
|
||||
currentTable = nil
|
||||
}
|
||||
case "enum":
|
||||
if currentEnum != nil {
|
||||
schema.Enums = append(schema.Enums, currentEnum)
|
||||
currentEnum = nil
|
||||
}
|
||||
}
|
||||
currentBlock = ""
|
||||
blockContent = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate block content
|
||||
if currentBlock != "" {
|
||||
if currentBlock == "enum" && currentEnum != nil {
|
||||
// For enums, just add the trimmed value
|
||||
if trimmed != "" {
|
||||
currentEnum.Values = append(currentEnum.Values, trimmed)
|
||||
}
|
||||
} else {
|
||||
blockContent = append(blockContent, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: resolve relationships
|
||||
r.resolveRelationships(schema)
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// parseDatasource extracts database type from datasource block
|
||||
func (r *Reader) parseDatasource(lines []string, db *models.Database) {
|
||||
providerRegex := regexp.MustCompile(`provider\s*=\s*"?(\w+)"?`)
|
||||
|
||||
for _, line := range lines {
|
||||
if matches := providerRegex.FindStringSubmatch(line); matches != nil {
|
||||
provider := matches[1]
|
||||
switch provider {
|
||||
case "postgresql", "postgres":
|
||||
db.DatabaseType = models.PostgresqlDatabaseType
|
||||
case "mysql":
|
||||
db.DatabaseType = "mysql"
|
||||
case "sqlite":
|
||||
db.DatabaseType = models.SqlLiteDatabaseType
|
||||
case "sqlserver":
|
||||
db.DatabaseType = models.MSSQLDatabaseType
|
||||
default:
|
||||
db.DatabaseType = models.PostgresqlDatabaseType
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseModelFields parses model field definitions
|
||||
func (r *Reader) parseModelFields(lines []string, table *models.Table) {
|
||||
fieldRegex := regexp.MustCompile(`^(\w+)\s+(\w+)(\?|\[\])?\s*(@.+)?`)
|
||||
blockAttrRegex := regexp.MustCompile(`^@@(\w+)\((.*?)\)`)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for block attributes (@@id, @@unique, @@index)
|
||||
if matches := blockAttrRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
attrName := matches[1]
|
||||
attrContent := matches[2]
|
||||
r.parseBlockAttribute(attrName, attrContent, table)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse field definition
|
||||
if matches := fieldRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
fieldName := matches[1]
|
||||
fieldType := matches[2]
|
||||
modifier := matches[3] // ? or []
|
||||
attributes := matches[4] // @... part
|
||||
|
||||
column := r.parseField(fieldName, fieldType, modifier, attributes, table)
|
||||
if column != nil {
|
||||
table.Columns[column.Name] = column
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseField parses a single field definition
|
||||
func (r *Reader) parseField(name, fieldType, modifier, attributes string, table *models.Table) *models.Column {
|
||||
// Check if this is a relation field (array or references another model)
|
||||
if modifier == "[]" {
|
||||
// Array field - this is a relation field, not a column
|
||||
// We'll handle this in relationship resolution
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a non-primitive type (relation field)
|
||||
// Note: We need to allow enum types through as they're like primitives
|
||||
if !r.isPrimitiveType(fieldType) && !r.isEnumType(fieldType, table) {
|
||||
// This is a relation field (e.g., user User), not a scalar column
|
||||
// Only process this if it has @relation attribute (which means it's the owning side with FK)
|
||||
// Otherwise skip it as it's just the inverse relation field
|
||||
if attributes == "" || !strings.Contains(attributes, "@relation") {
|
||||
return nil
|
||||
}
|
||||
// If it has @relation, we still don't create a column for it
|
||||
// The actual FK column will be in the fields: [...] part of @relation
|
||||
return nil
|
||||
}
|
||||
|
||||
column := models.InitColumn(name, table.Name, table.Schema)
|
||||
|
||||
// Map Prisma type to SQL type
|
||||
column.Type = r.prismaTypeToSQL(fieldType)
|
||||
|
||||
// Handle modifiers
|
||||
if modifier == "?" {
|
||||
column.NotNull = false
|
||||
} else {
|
||||
// Default: required fields are NOT NULL
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
// Parse field attributes
|
||||
if attributes != "" {
|
||||
r.parseFieldAttributes(attributes, column, table)
|
||||
}
|
||||
|
||||
return column
|
||||
}
|
||||
|
||||
// prismaTypeToSQL converts Prisma types to SQL types
|
||||
func (r *Reader) prismaTypeToSQL(prismaType string) string {
|
||||
typeMap := map[string]string{
|
||||
"String": "text",
|
||||
"Boolean": "boolean",
|
||||
"Int": "integer",
|
||||
"BigInt": "bigint",
|
||||
"Float": "double precision",
|
||||
"Decimal": "decimal",
|
||||
"DateTime": "timestamp",
|
||||
"Json": "jsonb",
|
||||
"Bytes": "bytea",
|
||||
}
|
||||
|
||||
if sqlType, ok := typeMap[prismaType]; ok {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// If not a built-in type, it might be an enum or model reference
|
||||
// For enums, we'll use the enum name directly
|
||||
return prismaType
|
||||
}
|
||||
|
||||
// parseFieldAttributes parses field attributes like @id, @unique, @default
|
||||
func (r *Reader) parseFieldAttributes(attributes string, column *models.Column, table *models.Table) {
|
||||
// @id attribute
|
||||
if strings.Contains(attributes, "@id") {
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
}
|
||||
|
||||
// @unique attribute
|
||||
if regexp.MustCompile(`@unique\b`).MatchString(attributes) {
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("uq_%s", column.Name),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = table.Schema
|
||||
uniqueConstraint.Table = table.Name
|
||||
uniqueConstraint.Columns = []string{column.Name}
|
||||
table.Constraints[uniqueConstraint.Name] = uniqueConstraint
|
||||
}
|
||||
|
||||
// @default attribute - extract value with balanced parentheses
|
||||
if strings.Contains(attributes, "@default(") {
|
||||
defaultValue := r.extractDefaultValue(attributes)
|
||||
if defaultValue != "" {
|
||||
r.parseDefaultValue(defaultValue, column)
|
||||
}
|
||||
}
|
||||
|
||||
// @updatedAt attribute - store in comment for now
|
||||
if strings.Contains(attributes, "@updatedAt") {
|
||||
if column.Comment != "" {
|
||||
column.Comment += "; @updatedAt"
|
||||
} else {
|
||||
column.Comment = "@updatedAt"
|
||||
}
|
||||
}
|
||||
|
||||
// @relation attribute - we'll handle this in relationship resolution
|
||||
// For now, just note that this field is part of a relation
|
||||
}
|
||||
|
||||
// extractDefaultValue extracts the default value from @default(...) handling nested parentheses
|
||||
func (r *Reader) extractDefaultValue(attributes string) string {
|
||||
idx := strings.Index(attributes, "@default(")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := idx + len("@default(")
|
||||
depth := 1
|
||||
i := start
|
||||
|
||||
for i < len(attributes) && depth > 0 {
|
||||
switch attributes[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if depth == 0 {
|
||||
return attributes[start : i-1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseDefaultValue parses Prisma default value expressions
|
||||
func (r *Reader) parseDefaultValue(defaultExpr string, column *models.Column) {
|
||||
defaultExpr = strings.TrimSpace(defaultExpr)
|
||||
|
||||
switch defaultExpr {
|
||||
case "autoincrement()":
|
||||
column.AutoIncrement = true
|
||||
case "now()":
|
||||
column.Default = "now()"
|
||||
case "uuid()":
|
||||
column.Default = "gen_random_uuid()"
|
||||
case "cuid()":
|
||||
// CUID is Prisma-specific, store in comment
|
||||
if column.Comment != "" {
|
||||
column.Comment += "; default(cuid())"
|
||||
} else {
|
||||
column.Comment = "default(cuid())"
|
||||
}
|
||||
case "true":
|
||||
column.Default = true
|
||||
case "false":
|
||||
column.Default = false
|
||||
default:
|
||||
// Check if it's a string literal
|
||||
if strings.HasPrefix(defaultExpr, "\"") && strings.HasSuffix(defaultExpr, "\"") {
|
||||
column.Default = defaultExpr[1 : len(defaultExpr)-1]
|
||||
} else if strings.HasPrefix(defaultExpr, "'") && strings.HasSuffix(defaultExpr, "'") {
|
||||
column.Default = defaultExpr[1 : len(defaultExpr)-1]
|
||||
} else {
|
||||
// Try to parse as number or enum value
|
||||
column.Default = defaultExpr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseBlockAttribute parses block-level attributes like @@id, @@unique, @@index
|
||||
func (r *Reader) parseBlockAttribute(attrName, content string, table *models.Table) {
|
||||
// Extract column list from brackets [col1, col2]
|
||||
colListRegex := regexp.MustCompile(`\[(.*?)\]`)
|
||||
matches := colListRegex.FindStringSubmatch(content)
|
||||
if matches == nil {
|
||||
return
|
||||
}
|
||||
|
||||
columnList := strings.Split(matches[1], ",")
|
||||
columns := make([]string, 0)
|
||||
for _, col := range columnList {
|
||||
columns = append(columns, strings.TrimSpace(col))
|
||||
}
|
||||
|
||||
switch attrName {
|
||||
case "id":
|
||||
// Composite primary key
|
||||
for _, colName := range columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
col.IsPrimaryKey = true
|
||||
col.NotNull = true
|
||||
}
|
||||
}
|
||||
// Also create a PK constraint
|
||||
pkConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("pk_%s", table.Name),
|
||||
models.PrimaryKeyConstraint,
|
||||
)
|
||||
pkConstraint.Schema = table.Schema
|
||||
pkConstraint.Table = table.Name
|
||||
pkConstraint.Columns = columns
|
||||
table.Constraints[pkConstraint.Name] = pkConstraint
|
||||
|
||||
case "unique":
|
||||
// Multi-column unique constraint
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("uq_%s_%s", table.Name, strings.Join(columns, "_")),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = table.Schema
|
||||
uniqueConstraint.Table = table.Name
|
||||
uniqueConstraint.Columns = columns
|
||||
table.Constraints[uniqueConstraint.Name] = uniqueConstraint
|
||||
|
||||
case "index":
|
||||
// Index
|
||||
index := models.InitIndex(
|
||||
fmt.Sprintf("idx_%s_%s", table.Name, strings.Join(columns, "_")),
|
||||
table.Name,
|
||||
table.Schema,
|
||||
)
|
||||
index.Columns = columns
|
||||
table.Indexes[index.Name] = index
|
||||
}
|
||||
}
|
||||
|
||||
// relationField stores information about a relation field for second-pass processing
|
||||
type relationField struct {
|
||||
tableName string
|
||||
fieldName string
|
||||
relatedModel string
|
||||
isArray bool
|
||||
relationAttr string
|
||||
}
|
||||
|
||||
// resolveRelationships performs a second pass to resolve @relation attributes
|
||||
func (r *Reader) resolveRelationships(schema *models.Schema) {
|
||||
// Build a map of table names for quick lookup
|
||||
tableMap := make(map[string]*models.Table)
|
||||
for _, table := range schema.Tables {
|
||||
tableMap[table.Name] = table
|
||||
}
|
||||
|
||||
// First, we need to re-parse to find relation fields
|
||||
// We'll re-read the file to extract relation information
|
||||
if r.options.FilePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
relations := r.extractRelationFields(string(content))
|
||||
|
||||
// Process explicit @relation attributes to create FK constraints
|
||||
for _, rel := range relations {
|
||||
if rel.relationAttr != "" {
|
||||
r.createConstraintFromRelation(rel, tableMap, schema)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect implicit many-to-many relationships
|
||||
r.detectImplicitManyToMany(relations, tableMap, schema)
|
||||
}
|
||||
|
||||
// extractRelationFields extracts relation field information from the schema
|
||||
func (r *Reader) extractRelationFields(content string) []relationField {
|
||||
relations := make([]relationField, 0)
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
modelRegex := regexp.MustCompile(`^model\s+(\w+)\s*{`)
|
||||
fieldRegex := regexp.MustCompile(`^(\w+)\s+(\w+)(\?|\[\])?\s*(@.+)?`)
|
||||
|
||||
var currentModel string
|
||||
inModel := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := modelRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentModel = matches[1]
|
||||
inModel = true
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "}" {
|
||||
inModel = false
|
||||
currentModel = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if inModel && currentModel != "" {
|
||||
if matches := fieldRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
fieldName := matches[1]
|
||||
fieldType := matches[2]
|
||||
modifier := matches[3]
|
||||
attributes := matches[4]
|
||||
|
||||
// Check if this is a relation field (references another model or is an array)
|
||||
isPotentialRelation := modifier == "[]" || !r.isPrimitiveType(fieldType)
|
||||
|
||||
if isPotentialRelation {
|
||||
rel := relationField{
|
||||
tableName: currentModel,
|
||||
fieldName: fieldName,
|
||||
relatedModel: fieldType,
|
||||
isArray: modifier == "[]",
|
||||
relationAttr: attributes,
|
||||
}
|
||||
relations = append(relations, rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relations
|
||||
}
|
||||
|
||||
// isPrimitiveType checks if a type is a Prisma primitive type
|
||||
func (r *Reader) isPrimitiveType(typeName string) bool {
|
||||
primitives := []string{"String", "Boolean", "Int", "BigInt", "Float", "Decimal", "DateTime", "Json", "Bytes"}
|
||||
for _, p := range primitives {
|
||||
if typeName == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isEnumType checks if a type name might be an enum
|
||||
// Note: We can't definitively check against schema.Enums at parse time
|
||||
// because enums might be defined after the model, so we just check
|
||||
// if it starts with uppercase (Prisma convention for enums)
|
||||
func (r *Reader) isEnumType(typeName string, table *models.Table) bool {
|
||||
// Simple heuristic: enum types start with uppercase letter
|
||||
// and are not known model names (though we can't check that yet)
|
||||
if len(typeName) > 0 && typeName[0] >= 'A' && typeName[0] <= 'Z' {
|
||||
// Additional check: primitive types are already handled above
|
||||
// So if it's uppercase and not primitive, it's likely an enum or model
|
||||
// We'll assume it's an enum if it's a single word
|
||||
return !strings.Contains(typeName, "_")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createConstraintFromRelation creates a FK constraint from a @relation attribute
|
||||
func (r *Reader) createConstraintFromRelation(rel relationField, tableMap map[string]*models.Table, schema *models.Schema) {
|
||||
// Skip array fields (they are the inverse side of the relation)
|
||||
if rel.isArray {
|
||||
return
|
||||
}
|
||||
|
||||
if rel.relationAttr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse @relation attribute
|
||||
relationRegex := regexp.MustCompile(`@relation\((.*?)\)`)
|
||||
matches := relationRegex.FindStringSubmatch(rel.relationAttr)
|
||||
if matches == nil {
|
||||
return
|
||||
}
|
||||
|
||||
relationContent := matches[1]
|
||||
|
||||
// Extract fields and references
|
||||
fieldsRegex := regexp.MustCompile(`fields:\s*\[(.*?)\]`)
|
||||
referencesRegex := regexp.MustCompile(`references:\s*\[(.*?)\]`)
|
||||
nameRegex := regexp.MustCompile(`name:\s*"([^"]+)"`)
|
||||
onDeleteRegex := regexp.MustCompile(`onDelete:\s*(\w+)`)
|
||||
onUpdateRegex := regexp.MustCompile(`onUpdate:\s*(\w+)`)
|
||||
|
||||
fieldsMatch := fieldsRegex.FindStringSubmatch(relationContent)
|
||||
referencesMatch := referencesRegex.FindStringSubmatch(relationContent)
|
||||
|
||||
if fieldsMatch == nil || referencesMatch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse field and reference column lists
|
||||
fieldCols := r.parseColumnList(fieldsMatch[1])
|
||||
refCols := r.parseColumnList(referencesMatch[1])
|
||||
|
||||
if len(fieldCols) == 0 || len(refCols) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Create FK constraint
|
||||
constraintName := fmt.Sprintf("fk_%s_%s", rel.tableName, fieldCols[0])
|
||||
|
||||
// Check for custom name
|
||||
if nameMatch := nameRegex.FindStringSubmatch(relationContent); nameMatch != nil {
|
||||
constraintName = nameMatch[1]
|
||||
}
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.ForeignKeyConstraint)
|
||||
constraint.Schema = "public"
|
||||
constraint.Table = rel.tableName
|
||||
constraint.Columns = fieldCols
|
||||
constraint.ReferencedSchema = "public"
|
||||
constraint.ReferencedTable = rel.relatedModel
|
||||
constraint.ReferencedColumns = refCols
|
||||
|
||||
// Parse referential actions
|
||||
if onDeleteMatch := onDeleteRegex.FindStringSubmatch(relationContent); onDeleteMatch != nil {
|
||||
constraint.OnDelete = onDeleteMatch[1]
|
||||
}
|
||||
|
||||
if onUpdateMatch := onUpdateRegex.FindStringSubmatch(relationContent); onUpdateMatch != nil {
|
||||
constraint.OnUpdate = onUpdateMatch[1]
|
||||
}
|
||||
|
||||
// Add constraint to table
|
||||
if table, exists := tableMap[rel.tableName]; exists {
|
||||
table.Constraints[constraint.Name] = constraint
|
||||
}
|
||||
}
|
||||
|
||||
// parseColumnList parses a comma-separated list of column names
|
||||
func (r *Reader) parseColumnList(list string) []string {
|
||||
parts := strings.Split(list, ",")
|
||||
result := make([]string, 0)
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// detectImplicitManyToMany detects implicit M2M relationships and creates join tables
|
||||
func (r *Reader) detectImplicitManyToMany(relations []relationField, tableMap map[string]*models.Table, schema *models.Schema) {
|
||||
// Group relations by model pairs
|
||||
type modelPair struct {
|
||||
model1 string
|
||||
model2 string
|
||||
}
|
||||
|
||||
pairMap := make(map[modelPair][]relationField)
|
||||
|
||||
for _, rel := range relations {
|
||||
if !rel.isArray || rel.relationAttr != "" {
|
||||
// Skip non-array fields and explicit relations
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a normalized pair (alphabetically sorted to avoid duplicates)
|
||||
pair := modelPair{}
|
||||
if rel.tableName < rel.relatedModel {
|
||||
pair.model1 = rel.tableName
|
||||
pair.model2 = rel.relatedModel
|
||||
} else {
|
||||
pair.model1 = rel.relatedModel
|
||||
pair.model2 = rel.tableName
|
||||
}
|
||||
|
||||
pairMap[pair] = append(pairMap[pair], rel)
|
||||
}
|
||||
|
||||
// Check for pairs with arrays on both sides (implicit M2M)
|
||||
for pair, rels := range pairMap {
|
||||
if len(rels) >= 2 {
|
||||
// This is an implicit many-to-many relationship
|
||||
r.createImplicitJoinTable(pair.model1, pair.model2, tableMap, schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createImplicitJoinTable creates a virtual join table for implicit M2M relations
|
||||
func (r *Reader) createImplicitJoinTable(model1, model2 string, tableMap map[string]*models.Table, schema *models.Schema) {
|
||||
// Prisma naming convention: _Model1ToModel2 (alphabetically sorted)
|
||||
joinTableName := fmt.Sprintf("_%sTo%s", model1, model2)
|
||||
|
||||
// Check if join table already exists
|
||||
if _, exists := tableMap[joinTableName]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Create join table
|
||||
joinTable := models.InitTable(joinTableName, "public")
|
||||
|
||||
// Get primary keys from both tables
|
||||
pk1 := r.getPrimaryKeyColumn(tableMap[model1])
|
||||
pk2 := r.getPrimaryKeyColumn(tableMap[model2])
|
||||
|
||||
if pk1 == nil || pk2 == nil {
|
||||
return // Can't create join table without PKs
|
||||
}
|
||||
|
||||
// Create FK columns in join table
|
||||
fkCol1Name := fmt.Sprintf("%sId", model1)
|
||||
fkCol1 := models.InitColumn(fkCol1Name, joinTableName, "public")
|
||||
fkCol1.Type = pk1.Type
|
||||
fkCol1.NotNull = true
|
||||
joinTable.Columns[fkCol1Name] = fkCol1
|
||||
|
||||
fkCol2Name := fmt.Sprintf("%sId", model2)
|
||||
fkCol2 := models.InitColumn(fkCol2Name, joinTableName, "public")
|
||||
fkCol2.Type = pk2.Type
|
||||
fkCol2.NotNull = true
|
||||
joinTable.Columns[fkCol2Name] = fkCol2
|
||||
|
||||
// Create composite primary key
|
||||
pkConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("pk_%s", joinTableName),
|
||||
models.PrimaryKeyConstraint,
|
||||
)
|
||||
pkConstraint.Schema = "public"
|
||||
pkConstraint.Table = joinTableName
|
||||
pkConstraint.Columns = []string{fkCol1Name, fkCol2Name}
|
||||
joinTable.Constraints[pkConstraint.Name] = pkConstraint
|
||||
|
||||
// Mark columns as PK
|
||||
fkCol1.IsPrimaryKey = true
|
||||
fkCol2.IsPrimaryKey = true
|
||||
|
||||
// Create FK constraints
|
||||
fk1 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, model1),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk1.Schema = "public"
|
||||
fk1.Table = joinTableName
|
||||
fk1.Columns = []string{fkCol1Name}
|
||||
fk1.ReferencedSchema = "public"
|
||||
fk1.ReferencedTable = model1
|
||||
fk1.ReferencedColumns = []string{pk1.Name}
|
||||
fk1.OnDelete = "Cascade"
|
||||
joinTable.Constraints[fk1.Name] = fk1
|
||||
|
||||
fk2 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, model2),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk2.Schema = "public"
|
||||
fk2.Table = joinTableName
|
||||
fk2.Columns = []string{fkCol2Name}
|
||||
fk2.ReferencedSchema = "public"
|
||||
fk2.ReferencedTable = model2
|
||||
fk2.ReferencedColumns = []string{pk2.Name}
|
||||
fk2.OnDelete = "Cascade"
|
||||
joinTable.Constraints[fk2.Name] = fk2
|
||||
|
||||
// Add join table to schema
|
||||
schema.Tables = append(schema.Tables, joinTable)
|
||||
tableMap[joinTableName] = joinTable
|
||||
}
|
||||
|
||||
// getPrimaryKeyColumn returns the primary key column of a table
|
||||
func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
return col
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
171
pkg/readers/sqldir/reader.go
Normal file
171
pkg/readers/sqldir/reader.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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.Script{
|
||||
Name: name,
|
||||
Description: fmt.Sprintf("SQL script from %s", relPath),
|
||||
SQL: string(content),
|
||||
Priority: priority,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
122
pkg/readers/typeorm/README.md
Normal file
122
pkg/readers/typeorm/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# TypeORM Reader
|
||||
|
||||
Reads TypeScript files containing TypeORM entity definitions and extracts database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The TypeORM Reader parses TypeScript source files that define TypeORM entities (classes with TypeORM decorators) and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses TypeORM decorators and entity definitions
|
||||
- Extracts table, column, and relationship information
|
||||
- Supports various TypeORM column types and options
|
||||
- Handles constraints, indexes, and relationships
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/entities/",
|
||||
}
|
||||
|
||||
reader := typeorm.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read TypeORM entities and convert to JSON
|
||||
relspec --input typeorm --in-file entities/ --output json --out-file schema.json
|
||||
|
||||
# Convert TypeORM to GORM models
|
||||
relspec --input typeorm --in-file User.ts --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Example TypeORM Entity
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Post } from './Post';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
email: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@OneToMany(() => Post, (post) => post.user)
|
||||
posts: Post[];
|
||||
}
|
||||
|
||||
@Entity('posts')
|
||||
export class Post {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
}
|
||||
```
|
||||
|
||||
## Supported TypeORM Decorators
|
||||
|
||||
- `@Entity()` - Entity/table definition
|
||||
- `@PrimaryGeneratedColumn()` - Auto-increment primary key
|
||||
- `@PrimaryColumn()` - Primary key
|
||||
- `@Column()` - Column definition
|
||||
- `@CreateDateColumn()` - Auto-set creation timestamp
|
||||
- `@UpdateDateColumn()` - Auto-update timestamp
|
||||
- `@OneToMany()` - One-to-many relationship
|
||||
- `@ManyToOne()` - Many-to-one relationship
|
||||
- `@JoinColumn()` - Foreign key column
|
||||
- `@Index()` - Index definition
|
||||
- `@Unique()` - Unique constraint
|
||||
|
||||
## Notes
|
||||
|
||||
- Schema name can be specified in `@Entity()` decorator
|
||||
- Supports both JavaScript and TypeScript entity files
|
||||
- Relationship metadata is extracted from decorators
|
||||
785
pkg/readers/typeorm/reader.go
Normal file
785
pkg/readers/typeorm/reader.go
Normal file
@@ -0,0 +1,785 @@
|
||||
package typeorm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for TypeORM entity files
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
}
|
||||
|
||||
// NewReader creates a new TypeORM reader with the given options
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads and parses TypeORM entity files, returning a Database model
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM reader")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(r.options.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return r.parseTypeORM(string(content))
|
||||
}
|
||||
|
||||
// ReadSchema reads and parses TypeORM entity files, returning a Schema model
|
||||
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 schemas found in TypeORM entities")
|
||||
}
|
||||
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable reads and parses TypeORM entity files, returning a Table model
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in TypeORM entities")
|
||||
}
|
||||
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// entityInfo stores information about an entity during parsing
|
||||
type entityInfo struct {
|
||||
name string
|
||||
fields []fieldInfo
|
||||
decorators []string
|
||||
}
|
||||
|
||||
// fieldInfo stores information about a field during parsing
|
||||
type fieldInfo struct {
|
||||
name string
|
||||
typeName string
|
||||
decorators []string
|
||||
}
|
||||
|
||||
// parseTypeORM parses TypeORM entity content and returns a Database model
|
||||
func (r *Reader) parseTypeORM(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Parse entities
|
||||
entities := r.extractEntities(content)
|
||||
|
||||
// Convert entities to tables and views
|
||||
tableMap := make(map[string]*models.Table)
|
||||
for _, entity := range entities {
|
||||
// Check if this is a view
|
||||
isView := false
|
||||
for _, decorator := range entity.decorators {
|
||||
if strings.HasPrefix(decorator, "@ViewEntity") {
|
||||
isView = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isView {
|
||||
view := r.entityToView(entity)
|
||||
schema.Views = append(schema.Views, view)
|
||||
} else {
|
||||
table := r.entityToTable(entity)
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
tableMap[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: resolve relationships
|
||||
r.resolveRelationships(entities, tableMap, schema)
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// extractEntities extracts entity and view definitions from TypeORM content
|
||||
func (r *Reader) extractEntities(content string) []entityInfo {
|
||||
entities := make([]entityInfo, 0)
|
||||
|
||||
// First, extract decorators properly (handling multi-line)
|
||||
content = r.normalizeDecorators(content)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
entityRegex := regexp.MustCompile(`^export\s+class\s+(\w+)`)
|
||||
decoratorRegex := regexp.MustCompile(`^\s*@(\w+)(\([^)]*\))?`)
|
||||
fieldRegex := regexp.MustCompile(`^\s*(\w+):\s*([^;]+);`)
|
||||
|
||||
var currentEntity *entityInfo
|
||||
var pendingDecorators []string
|
||||
inClass := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "import ") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for decorator
|
||||
if matches := decoratorRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
decorator := matches[0]
|
||||
pendingDecorators = append(pendingDecorators, decorator)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for entity/view class
|
||||
if matches := entityRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
// Save previous entity if exists
|
||||
if currentEntity != nil {
|
||||
entities = append(entities, *currentEntity)
|
||||
}
|
||||
|
||||
currentEntity = &entityInfo{
|
||||
name: matches[1],
|
||||
fields: make([]fieldInfo, 0),
|
||||
decorators: pendingDecorators,
|
||||
}
|
||||
pendingDecorators = []string{}
|
||||
inClass = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for class end
|
||||
if inClass && trimmed == "}" {
|
||||
if currentEntity != nil {
|
||||
entities = append(entities, *currentEntity)
|
||||
currentEntity = nil
|
||||
}
|
||||
inClass = false
|
||||
pendingDecorators = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for field definition
|
||||
if inClass && currentEntity != nil {
|
||||
if matches := fieldRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
fieldName := matches[1]
|
||||
fieldType := strings.TrimSpace(matches[2])
|
||||
|
||||
field := fieldInfo{
|
||||
name: fieldName,
|
||||
typeName: fieldType,
|
||||
decorators: pendingDecorators,
|
||||
}
|
||||
currentEntity.fields = append(currentEntity.fields, field)
|
||||
pendingDecorators = []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last entity
|
||||
if currentEntity != nil {
|
||||
entities = append(entities, *currentEntity)
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
|
||||
// normalizeDecorators combines multi-line decorators into single lines
|
||||
func (r *Reader) normalizeDecorators(content string) string {
|
||||
// Replace multi-line decorators with single-line versions
|
||||
// Match @Decorator({ ... }) across multiple lines
|
||||
decoratorRegex := regexp.MustCompile(`@(\w+)\s*\(\s*\{([^}]*)\}\s*\)`)
|
||||
|
||||
return decoratorRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
// Remove newlines and extra spaces from decorator
|
||||
match = strings.ReplaceAll(match, "\n", " ")
|
||||
match = strings.ReplaceAll(match, "\r", " ")
|
||||
// Normalize multiple spaces
|
||||
spaceRegex := regexp.MustCompile(`\s+`)
|
||||
match = spaceRegex.ReplaceAllString(match, " ")
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// entityToView converts a view entity to a view
|
||||
func (r *Reader) entityToView(entity entityInfo) *models.View {
|
||||
// Parse @ViewEntity decorator options
|
||||
viewName := entity.name
|
||||
schemaName := "public"
|
||||
var expression string
|
||||
|
||||
for _, decorator := range entity.decorators {
|
||||
if strings.HasPrefix(decorator, "@ViewEntity") {
|
||||
// Extract options from @ViewEntity({ ... })
|
||||
options := r.parseViewEntityOptions(decorator)
|
||||
|
||||
// Check for custom view name
|
||||
if name, ok := options["name"]; ok {
|
||||
viewName = name
|
||||
}
|
||||
|
||||
// Check for schema
|
||||
if schema, ok := options["schema"]; ok {
|
||||
schemaName = schema
|
||||
}
|
||||
|
||||
// Check for expression (SQL definition)
|
||||
if expr, ok := options["expression"]; ok {
|
||||
expression = expr
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
view := models.InitView(viewName, schemaName)
|
||||
view.Definition = expression
|
||||
|
||||
// Add columns from fields (if any are defined in the view class)
|
||||
for _, field := range entity.fields {
|
||||
column := models.InitColumn(field.name, viewName, schemaName)
|
||||
column.Type = r.typeScriptTypeToSQL(field.typeName)
|
||||
view.Columns[column.Name] = column
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
// parseViewEntityOptions parses @ViewEntity decorator options
|
||||
func (r *Reader) parseViewEntityOptions(decorator string) map[string]string {
|
||||
options := make(map[string]string)
|
||||
|
||||
// Extract content between parentheses
|
||||
start := strings.Index(decorator, "(")
|
||||
end := strings.LastIndex(decorator, ")")
|
||||
|
||||
if start == -1 || end == -1 || start >= end {
|
||||
return options
|
||||
}
|
||||
|
||||
content := decorator[start+1 : end]
|
||||
|
||||
// Skip if empty @ViewEntity()
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
// Parse name: "value"
|
||||
nameRegex := regexp.MustCompile(`name:\s*["']([^"']+)["']`)
|
||||
if matches := nameRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["name"] = matches[1]
|
||||
}
|
||||
|
||||
// Parse schema: "value"
|
||||
schemaRegex := regexp.MustCompile(`schema:\s*["']([^"']+)["']`)
|
||||
if matches := schemaRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["schema"] = matches[1]
|
||||
}
|
||||
|
||||
// Parse expression: ` ... ` (can be multi-line, captured as single line after normalization)
|
||||
// Look for expression followed by backtick or quote
|
||||
expressionRegex := regexp.MustCompile(`expression:\s*` + "`" + `([^` + "`" + `]+)` + "`")
|
||||
if matches := expressionRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["expression"] = strings.TrimSpace(matches[1])
|
||||
} else {
|
||||
// Try with regular quotes
|
||||
expressionRegex = regexp.MustCompile(`expression:\s*["']([^"']+)["']`)
|
||||
if matches := expressionRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["expression"] = strings.TrimSpace(matches[1])
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// entityToTable converts an entity to a table
|
||||
func (r *Reader) entityToTable(entity entityInfo) *models.Table {
|
||||
// Parse @Entity decorator options
|
||||
tableName := entity.name
|
||||
schemaName := "public"
|
||||
var entityOptions map[string]string
|
||||
|
||||
for _, decorator := range entity.decorators {
|
||||
if strings.HasPrefix(decorator, "@Entity") {
|
||||
// Extract options from @Entity({ ... })
|
||||
entityOptions = r.parseEntityOptions(decorator)
|
||||
|
||||
// Check for custom table name
|
||||
if name, ok := entityOptions["name"]; ok {
|
||||
tableName = name
|
||||
}
|
||||
|
||||
// Check for schema
|
||||
if schema, ok := entityOptions["schema"]; ok {
|
||||
schemaName = schema
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
table := models.InitTable(tableName, schemaName)
|
||||
|
||||
// Store additional metadata from @Entity options
|
||||
if entityOptions != nil {
|
||||
// Store database name in metadata
|
||||
if database, ok := entityOptions["database"]; ok {
|
||||
if table.Metadata == nil {
|
||||
table.Metadata = make(map[string]any)
|
||||
}
|
||||
table.Metadata["database"] = database
|
||||
}
|
||||
|
||||
// Store engine in metadata
|
||||
if engine, ok := entityOptions["engine"]; ok {
|
||||
if table.Metadata == nil {
|
||||
table.Metadata = make(map[string]any)
|
||||
}
|
||||
table.Metadata["engine"] = engine
|
||||
}
|
||||
|
||||
// Store original class name if different from table name
|
||||
if entity.name != tableName {
|
||||
if table.Metadata == nil {
|
||||
table.Metadata = make(map[string]any)
|
||||
}
|
||||
table.Metadata["class_name"] = entity.name
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range entity.fields {
|
||||
// Skip relation fields (they'll be handled in relationship resolution)
|
||||
if r.isRelationField(field) {
|
||||
continue
|
||||
}
|
||||
|
||||
column := r.fieldToColumn(field, table)
|
||||
if column != nil {
|
||||
table.Columns[column.Name] = column
|
||||
}
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
// parseEntityOptions parses @Entity decorator options
|
||||
func (r *Reader) parseEntityOptions(decorator string) map[string]string {
|
||||
options := make(map[string]string)
|
||||
|
||||
// Extract content between parentheses
|
||||
start := strings.Index(decorator, "(")
|
||||
end := strings.LastIndex(decorator, ")")
|
||||
|
||||
if start == -1 || end == -1 || start >= end {
|
||||
return options
|
||||
}
|
||||
|
||||
content := decorator[start+1 : end]
|
||||
|
||||
// Skip if empty @Entity()
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
// Parse name: "value" or name: 'value'
|
||||
nameRegex := regexp.MustCompile(`name:\s*["']([^"']+)["']`)
|
||||
if matches := nameRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["name"] = matches[1]
|
||||
}
|
||||
|
||||
// Parse schema: "value"
|
||||
schemaRegex := regexp.MustCompile(`schema:\s*["']([^"']+)["']`)
|
||||
if matches := schemaRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["schema"] = matches[1]
|
||||
}
|
||||
|
||||
// Parse database: "value"
|
||||
databaseRegex := regexp.MustCompile(`database:\s*["']([^"']+)["']`)
|
||||
if matches := databaseRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["database"] = matches[1]
|
||||
}
|
||||
|
||||
// Parse engine: "value"
|
||||
engineRegex := regexp.MustCompile(`engine:\s*["']([^"']+)["']`)
|
||||
if matches := engineRegex.FindStringSubmatch(content); matches != nil {
|
||||
options["engine"] = matches[1]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// isRelationField checks if a field is a relation field
|
||||
func (r *Reader) isRelationField(field fieldInfo) bool {
|
||||
for _, decorator := range field.decorators {
|
||||
if strings.Contains(decorator, "@ManyToOne") ||
|
||||
strings.Contains(decorator, "@OneToMany") ||
|
||||
strings.Contains(decorator, "@ManyToMany") ||
|
||||
strings.Contains(decorator, "@OneToOne") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fieldToColumn converts a field to a column
|
||||
func (r *Reader) fieldToColumn(field fieldInfo, table *models.Table) *models.Column {
|
||||
column := models.InitColumn(field.name, table.Name, table.Schema)
|
||||
|
||||
// Map TypeScript type to SQL type
|
||||
column.Type = r.typeScriptTypeToSQL(field.typeName)
|
||||
|
||||
// Default to NOT NULL
|
||||
column.NotNull = true
|
||||
|
||||
// Parse decorators
|
||||
for _, decorator := range field.decorators {
|
||||
r.parseColumnDecorator(decorator, column, table)
|
||||
}
|
||||
|
||||
return column
|
||||
}
|
||||
|
||||
// typeScriptTypeToSQL converts TypeScript types to SQL types
|
||||
func (r *Reader) typeScriptTypeToSQL(tsType string) string {
|
||||
// Remove array brackets and optional markers
|
||||
tsType = strings.TrimSuffix(tsType, "[]")
|
||||
tsType = strings.TrimSuffix(tsType, " | null")
|
||||
|
||||
typeMap := map[string]string{
|
||||
"string": "text",
|
||||
"number": "integer",
|
||||
"boolean": "boolean",
|
||||
"Date": "timestamp",
|
||||
"any": "jsonb",
|
||||
}
|
||||
|
||||
for tsPattern, sqlType := range typeMap {
|
||||
if strings.Contains(tsType, tsPattern) {
|
||||
return sqlType
|
||||
}
|
||||
}
|
||||
|
||||
// Default to text
|
||||
return "text"
|
||||
}
|
||||
|
||||
// parseColumnDecorator parses a column decorator
|
||||
func (r *Reader) parseColumnDecorator(decorator string, column *models.Column, table *models.Table) {
|
||||
// @PrimaryGeneratedColumn
|
||||
if strings.HasPrefix(decorator, "@PrimaryGeneratedColumn") {
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
|
||||
if strings.Contains(decorator, "'uuid'") {
|
||||
column.Type = "uuid"
|
||||
column.Default = "gen_random_uuid()"
|
||||
} else if strings.Contains(decorator, "'increment'") || strings.Contains(decorator, "()") {
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// @Column
|
||||
if strings.HasPrefix(decorator, "@Column") {
|
||||
r.parseColumnOptions(decorator, column, table)
|
||||
return
|
||||
}
|
||||
|
||||
// @CreateDateColumn
|
||||
if strings.HasPrefix(decorator, "@CreateDateColumn") {
|
||||
column.Type = "timestamp"
|
||||
column.Default = "now()"
|
||||
column.NotNull = true
|
||||
return
|
||||
}
|
||||
|
||||
// @UpdateDateColumn
|
||||
if strings.HasPrefix(decorator, "@UpdateDateColumn") {
|
||||
column.Type = "timestamp"
|
||||
column.NotNull = true
|
||||
if column.Comment != "" {
|
||||
column.Comment += "; auto-update"
|
||||
} else {
|
||||
column.Comment = "auto-update"
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// parseColumnOptions parses @Column decorator options
|
||||
func (r *Reader) parseColumnOptions(decorator string, column *models.Column, table *models.Table) {
|
||||
// Extract content between parentheses
|
||||
start := strings.Index(decorator, "(")
|
||||
end := strings.LastIndex(decorator, ")")
|
||||
|
||||
if start == -1 || end == -1 || start >= end {
|
||||
return
|
||||
}
|
||||
|
||||
content := decorator[start+1 : end]
|
||||
|
||||
// Check for shorthand type: @Column('text')
|
||||
if strings.HasPrefix(content, "'") || strings.HasPrefix(content, "\"") {
|
||||
typeStr := strings.Trim(content, "'\"`")
|
||||
column.Type = typeStr
|
||||
return
|
||||
}
|
||||
|
||||
// Parse options object
|
||||
if strings.Contains(content, "type:") {
|
||||
typeRegex := regexp.MustCompile(`type:\s*['"]([^'"]+)['"]`)
|
||||
if matches := typeRegex.FindStringSubmatch(content); matches != nil {
|
||||
column.Type = matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(content, "nullable: true") || strings.Contains(content, "nullable:true") {
|
||||
column.NotNull = false
|
||||
}
|
||||
|
||||
if strings.Contains(content, "unique: true") || strings.Contains(content, "unique:true") {
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("uq_%s", column.Name),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = table.Schema
|
||||
uniqueConstraint.Table = table.Name
|
||||
uniqueConstraint.Columns = []string{column.Name}
|
||||
table.Constraints[uniqueConstraint.Name] = uniqueConstraint
|
||||
}
|
||||
|
||||
if strings.Contains(content, "default:") {
|
||||
defaultRegex := regexp.MustCompile(`default:\s*['"]?([^,}'"]+)['"]?`)
|
||||
if matches := defaultRegex.FindStringSubmatch(content); matches != nil {
|
||||
defaultValue := strings.TrimSpace(matches[1])
|
||||
defaultValue = strings.Trim(defaultValue, "'\"")
|
||||
column.Default = defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRelationships resolves TypeORM relationships
|
||||
func (r *Reader) resolveRelationships(entities []entityInfo, tableMap map[string]*models.Table, schema *models.Schema) {
|
||||
// Track M2M relations that need join tables
|
||||
type m2mRelation struct {
|
||||
ownerEntity string
|
||||
targetEntity string
|
||||
ownerField string
|
||||
}
|
||||
m2mRelations := make([]m2mRelation, 0)
|
||||
|
||||
for _, entity := range entities {
|
||||
table := tableMap[entity.name]
|
||||
if table == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, field := range entity.fields {
|
||||
// Handle @ManyToOne relations
|
||||
if r.hasDecorator(field, "@ManyToOne") {
|
||||
r.createManyToOneConstraint(field, entity.name, table, tableMap)
|
||||
}
|
||||
|
||||
// Track @ManyToMany relations with @JoinTable
|
||||
if r.hasDecorator(field, "@ManyToMany") && r.hasDecorator(field, "@JoinTable") {
|
||||
targetEntity := r.extractRelationTarget(field)
|
||||
if targetEntity != "" {
|
||||
m2mRelations = append(m2mRelations, m2mRelation{
|
||||
ownerEntity: entity.name,
|
||||
targetEntity: targetEntity,
|
||||
ownerField: field.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create join tables for M2M relations
|
||||
for _, rel := range m2mRelations {
|
||||
r.createManyToManyJoinTable(rel.ownerEntity, rel.targetEntity, tableMap, schema)
|
||||
}
|
||||
}
|
||||
|
||||
// hasDecorator checks if a field has a specific decorator
|
||||
func (r *Reader) hasDecorator(field fieldInfo, decoratorName string) bool {
|
||||
for _, decorator := range field.decorators {
|
||||
if strings.HasPrefix(decorator, decoratorName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractRelationTarget extracts the target entity from a relation decorator
|
||||
func (r *Reader) extractRelationTarget(field fieldInfo) string {
|
||||
// Remove array brackets from type
|
||||
targetType := strings.TrimSuffix(field.typeName, "[]")
|
||||
targetType = strings.TrimSpace(targetType)
|
||||
return targetType
|
||||
}
|
||||
|
||||
// createManyToOneConstraint creates a foreign key constraint for @ManyToOne
|
||||
func (r *Reader) createManyToOneConstraint(field fieldInfo, entityName string, table *models.Table, tableMap map[string]*models.Table) {
|
||||
targetEntity := r.extractRelationTarget(field)
|
||||
if targetEntity == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get target table to find its PK
|
||||
targetTable := tableMap[targetEntity]
|
||||
if targetTable == nil {
|
||||
return
|
||||
}
|
||||
|
||||
targetPK := r.getPrimaryKeyColumn(targetTable)
|
||||
if targetPK == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create FK column
|
||||
fkColumnName := fmt.Sprintf("%sId", field.name)
|
||||
fkColumn := models.InitColumn(fkColumnName, table.Name, table.Schema)
|
||||
fkColumn.Type = targetPK.Type
|
||||
|
||||
// Check if nullable option is set in @ManyToOne decorator
|
||||
isNullable := false
|
||||
for _, decorator := range field.decorators {
|
||||
if strings.Contains(decorator, "nullable: true") || strings.Contains(decorator, "nullable:true") {
|
||||
isNullable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
fkColumn.NotNull = !isNullable
|
||||
|
||||
table.Columns[fkColumnName] = fkColumn
|
||||
|
||||
// Create FK constraint
|
||||
constraint := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", entityName, field.name),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
constraint.Schema = table.Schema
|
||||
constraint.Table = table.Name
|
||||
constraint.Columns = []string{fkColumnName}
|
||||
constraint.ReferencedSchema = "public"
|
||||
constraint.ReferencedTable = targetEntity
|
||||
constraint.ReferencedColumns = []string{targetPK.Name}
|
||||
constraint.OnDelete = "CASCADE"
|
||||
|
||||
table.Constraints[constraint.Name] = constraint
|
||||
}
|
||||
|
||||
// createManyToManyJoinTable creates a join table for M2M relations
|
||||
func (r *Reader) createManyToManyJoinTable(entity1, entity2 string, tableMap map[string]*models.Table, schema *models.Schema) {
|
||||
// TypeORM naming convention: entity1_entity2_entity1field
|
||||
// We'll simplify to entity1_entity2
|
||||
joinTableName := fmt.Sprintf("%s_%s", strings.ToLower(entity1), strings.ToLower(entity2))
|
||||
|
||||
// Check if join table already exists
|
||||
if _, exists := tableMap[joinTableName]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Get PKs from both tables
|
||||
table1 := tableMap[entity1]
|
||||
table2 := tableMap[entity2]
|
||||
if table1 == nil || table2 == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pk1 := r.getPrimaryKeyColumn(table1)
|
||||
pk2 := r.getPrimaryKeyColumn(table2)
|
||||
if pk1 == nil || pk2 == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create join table
|
||||
joinTable := models.InitTable(joinTableName, "public")
|
||||
|
||||
// Create FK columns
|
||||
fkCol1Name := fmt.Sprintf("%sId", strings.ToLower(entity1))
|
||||
fkCol1 := models.InitColumn(fkCol1Name, joinTableName, "public")
|
||||
fkCol1.Type = pk1.Type
|
||||
fkCol1.NotNull = true
|
||||
fkCol1.IsPrimaryKey = true
|
||||
joinTable.Columns[fkCol1Name] = fkCol1
|
||||
|
||||
fkCol2Name := fmt.Sprintf("%sId", strings.ToLower(entity2))
|
||||
fkCol2 := models.InitColumn(fkCol2Name, joinTableName, "public")
|
||||
fkCol2.Type = pk2.Type
|
||||
fkCol2.NotNull = true
|
||||
fkCol2.IsPrimaryKey = true
|
||||
joinTable.Columns[fkCol2Name] = fkCol2
|
||||
|
||||
// Create composite PK constraint
|
||||
pkConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("pk_%s", joinTableName),
|
||||
models.PrimaryKeyConstraint,
|
||||
)
|
||||
pkConstraint.Schema = "public"
|
||||
pkConstraint.Table = joinTableName
|
||||
pkConstraint.Columns = []string{fkCol1Name, fkCol2Name}
|
||||
joinTable.Constraints[pkConstraint.Name] = pkConstraint
|
||||
|
||||
// Create FK constraints
|
||||
fk1 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, entity1),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk1.Schema = "public"
|
||||
fk1.Table = joinTableName
|
||||
fk1.Columns = []string{fkCol1Name}
|
||||
fk1.ReferencedSchema = "public"
|
||||
fk1.ReferencedTable = entity1
|
||||
fk1.ReferencedColumns = []string{pk1.Name}
|
||||
fk1.OnDelete = "CASCADE"
|
||||
joinTable.Constraints[fk1.Name] = fk1
|
||||
|
||||
fk2 := models.InitConstraint(
|
||||
fmt.Sprintf("fk_%s_%s", joinTableName, entity2),
|
||||
models.ForeignKeyConstraint,
|
||||
)
|
||||
fk2.Schema = "public"
|
||||
fk2.Table = joinTableName
|
||||
fk2.Columns = []string{fkCol2Name}
|
||||
fk2.ReferencedSchema = "public"
|
||||
fk2.ReferencedTable = entity2
|
||||
fk2.ReferencedColumns = []string{pk2.Name}
|
||||
fk2.OnDelete = "CASCADE"
|
||||
joinTable.Constraints[fk2.Name] = fk2
|
||||
|
||||
// Add join table to schema
|
||||
schema.Tables = append(schema.Tables, joinTable)
|
||||
tableMap[joinTableName] = joinTable
|
||||
}
|
||||
|
||||
// getPrimaryKeyColumn returns the primary key column of a table
|
||||
func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
return col
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
159
pkg/readers/yaml/README.md
Normal file
159
pkg/readers/yaml/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# YAML Reader
|
||||
|
||||
Reads database schema definitions from YAML files.
|
||||
|
||||
## Overview
|
||||
|
||||
The YAML Reader parses YAML files that define database schemas in RelSpec's canonical YAML format and converts them into RelSpec's internal database model representation.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads RelSpec's standard YAML schema format
|
||||
- Human-readable alternative to JSON format
|
||||
- Supports complete schema representation including:
|
||||
- Databases and schemas
|
||||
- Tables, columns, and data types
|
||||
- Constraints (PK, FK, unique, check)
|
||||
- Indexes
|
||||
- Relationships
|
||||
- Views and sequences
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/path/to/schema.yaml",
|
||||
}
|
||||
|
||||
reader := yaml.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d schemas\n", len(db.Schemas))
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
# Read YAML schema and convert to GORM models
|
||||
relspec --input yaml --in-file schema.yaml --output gorm --out-file models.go
|
||||
|
||||
# Convert YAML to PostgreSQL DDL
|
||||
relspec --input yaml --in-file database.yaml --output pgsql --out-file schema.sql
|
||||
|
||||
# Transform YAML to JSON
|
||||
relspec --input yaml --in-file schema.yaml --output json --out-file schema.json
|
||||
```
|
||||
|
||||
## Example YAML Schema
|
||||
|
||||
```yaml
|
||||
name: myapp
|
||||
database_type: postgresql
|
||||
schemas:
|
||||
- name: public
|
||||
tables:
|
||||
- name: users
|
||||
schema: public
|
||||
columns:
|
||||
id:
|
||||
name: id
|
||||
type: bigint
|
||||
not_null: true
|
||||
is_primary_key: true
|
||||
auto_increment: true
|
||||
sequence: 1
|
||||
username:
|
||||
name: username
|
||||
type: varchar
|
||||
length: 50
|
||||
not_null: true
|
||||
sequence: 2
|
||||
email:
|
||||
name: email
|
||||
type: varchar
|
||||
length: 100
|
||||
not_null: true
|
||||
sequence: 3
|
||||
constraints:
|
||||
pk_users:
|
||||
name: pk_users
|
||||
type: PRIMARY KEY
|
||||
columns:
|
||||
- id
|
||||
uq_users_username:
|
||||
name: uq_users_username
|
||||
type: UNIQUE
|
||||
columns:
|
||||
- username
|
||||
indexes:
|
||||
idx_users_email:
|
||||
name: idx_users_email
|
||||
columns:
|
||||
- email
|
||||
unique: false
|
||||
type: btree
|
||||
- name: posts
|
||||
schema: public
|
||||
columns:
|
||||
id:
|
||||
name: id
|
||||
type: bigint
|
||||
not_null: true
|
||||
is_primary_key: true
|
||||
sequence: 1
|
||||
user_id:
|
||||
name: user_id
|
||||
type: bigint
|
||||
not_null: true
|
||||
sequence: 2
|
||||
title:
|
||||
name: title
|
||||
type: varchar
|
||||
length: 200
|
||||
not_null: true
|
||||
sequence: 3
|
||||
constraints:
|
||||
fk_posts_user_id:
|
||||
name: fk_posts_user_id
|
||||
type: FOREIGN KEY
|
||||
columns:
|
||||
- user_id
|
||||
referenced_table: users
|
||||
referenced_schema: public
|
||||
referenced_columns:
|
||||
- id
|
||||
on_delete: CASCADE
|
||||
on_update: NO ACTION
|
||||
```
|
||||
|
||||
## Schema Structure
|
||||
|
||||
The YAML format mirrors RelSpec's internal model structure with human-readable syntax:
|
||||
|
||||
- Database level: `name`, `database_type`, `schemas`
|
||||
- Schema level: `name`, `tables`, `views`, `sequences`
|
||||
- Table level: `name`, `schema`, `columns`, `constraints`, `indexes`, `relationships`
|
||||
- Column level: `name`, `type`, `length`, `not_null`, `default`, etc.
|
||||
|
||||
## Notes
|
||||
|
||||
- YAML format is more human-readable than JSON
|
||||
- Ideal for manual editing and version control
|
||||
- Comments are supported in YAML
|
||||
- Preserves complete schema information
|
||||
- Can be used for configuration and documentation
|
||||
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)
|
||||
}
|
||||
129
pkg/writers/bun/README.md
Normal file
129
pkg/writers/bun/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Bun Writer
|
||||
|
||||
Generates Go source files with Bun model definitions from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Bun Writer converts RelSpec's internal database model representation into Go source code with Bun struct definitions, complete with proper tags, relationships, and table configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates Bun-compatible Go structs
|
||||
- Creates proper `bun` struct tags
|
||||
- Adds relationship fields
|
||||
- Supports both single-file and multi-file output
|
||||
- Maps SQL types to Go types
|
||||
- Handles nullable fields with sql.Null* types
|
||||
- Generates table aliases
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
}
|
||||
|
||||
writer := bun.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate Bun models from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output bun \
|
||||
--out-file models.go \
|
||||
--package models
|
||||
|
||||
# Convert GORM models to Bun
|
||||
relspec --input gorm --in-file gorm_models.go --output bun --out-file bun_models.go
|
||||
|
||||
# Multi-file output
|
||||
relspec --input json --in-file schema.json --output bun --out-file models/
|
||||
```
|
||||
|
||||
## Generated Code Example
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"database/sql"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Username string `bun:"username,notnull,unique" json:"username"`
|
||||
Email string `bun:"email,notnull" json:"email"`
|
||||
Bio sql.NullString `bun:"bio" json:"bio,omitempty"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:now()" json:"created_at"`
|
||||
|
||||
// Relationships
|
||||
Posts []*Post `bun:"rel:has-many,join:id=user_id" json:"posts,omitempty"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
bun.BaseModel `bun:"table:posts,alias:p"`
|
||||
|
||||
ID int64 `bun:"id,pk" json:"id"`
|
||||
UserID int64 `bun:"user_id,notnull" json:"user_id"`
|
||||
Title string `bun:"title,notnull" json:"title"`
|
||||
Content sql.NullString `bun:"content" json:"content,omitempty"`
|
||||
|
||||
// Belongs to
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Bun Tags
|
||||
|
||||
- `table` - Table name and alias
|
||||
- `column` - Column name (auto-derived if not specified)
|
||||
- `pk` - Primary key
|
||||
- `autoincrement` - Auto-increment
|
||||
- `notnull` - NOT NULL constraint
|
||||
- `unique` - Unique constraint
|
||||
- `default` - Default value
|
||||
- `rel` - Relationship definition
|
||||
- `type` - Explicit SQL type
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | Go Type | Nullable Type |
|
||||
|----------|---------|---------------|
|
||||
| bigint | int64 | sql.NullInt64 |
|
||||
| integer | int | sql.NullInt32 |
|
||||
| varchar, text | string | sql.NullString |
|
||||
| boolean | bool | sql.NullBool |
|
||||
| timestamp | time.Time | sql.NullTime |
|
||||
| numeric | float64 | sql.NullFloat64 |
|
||||
|
||||
## Notes
|
||||
|
||||
- Model names are derived from table names (singularized, PascalCase)
|
||||
- Table aliases are auto-generated from table names
|
||||
- Multi-file mode: one file per table named `sql_{schema}_{table}.go`
|
||||
- Generated code is auto-formatted
|
||||
- JSON tags are automatically added
|
||||
@@ -41,12 +41,7 @@ func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
// WriteDatabase writes a complete database as Bun models
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Check if multi-file mode is enabled
|
||||
multiFile := false
|
||||
if w.options.Metadata != nil {
|
||||
if mf, ok := w.options.Metadata["multi_file"].(bool); ok {
|
||||
multiFile = mf
|
||||
}
|
||||
}
|
||||
multiFile := w.shouldUseMultiFile()
|
||||
|
||||
if multiFile {
|
||||
return w.writeMultiFile(db)
|
||||
@@ -346,6 +341,41 @@ func (w *Writer) writeOutput(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
if w.options.Metadata != nil {
|
||||
if mf, ok := w.options.Metadata["multi_file"].(bool); ok {
|
||||
return mf
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect based on output path
|
||||
if w.options.OutputPath == "" {
|
||||
// No output path means stdout (single file)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with .go (explicit file)
|
||||
if strings.HasSuffix(w.options.OutputPath, ".go") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with directory separator
|
||||
if strings.HasSuffix(w.options.OutputPath, "/") || strings.HasSuffix(w.options.OutputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
info, err := os.Stat(w.options.OutputPath)
|
||||
if err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to single file for ambiguous cases
|
||||
return false
|
||||
}
|
||||
|
||||
// createDatabaseRef creates a shallow copy of database without schemas to avoid circular references
|
||||
func (w *Writer) createDatabaseRef(db *models.Database) *models.Database {
|
||||
return &models.Database{
|
||||
|
||||
161
pkg/writers/dbml/README.md
Normal file
161
pkg/writers/dbml/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# DBML Writer
|
||||
|
||||
Generates Database Markup Language (DBML) files from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DBML Writer converts RelSpec's internal database model representation into DBML syntax, suitable for use with dbdiagram.io and other DBML-compatible tools.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates DBML syntax
|
||||
- Creates table definitions with columns
|
||||
- Defines relationships
|
||||
- Includes indexes
|
||||
- Adds notes and documentation
|
||||
- Supports enums
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.dbml",
|
||||
}
|
||||
|
||||
writer := dbml.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate DBML from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output dbml \
|
||||
--out-file schema.dbml
|
||||
|
||||
# Convert GORM models to DBML
|
||||
relspec --input gorm --in-file models.go --output dbml --out-file database.dbml
|
||||
|
||||
# Convert JSON to DBML for visualization
|
||||
relspec --input json --in-file schema.json --output dbml --out-file diagram.dbml
|
||||
```
|
||||
|
||||
## Generated DBML Example
|
||||
|
||||
```dbml
|
||||
Project MyDatabase {
|
||||
database_type: 'PostgreSQL'
|
||||
}
|
||||
|
||||
Table users {
|
||||
id bigserial [pk, increment]
|
||||
username varchar(50) [not null, unique]
|
||||
email varchar(100) [not null]
|
||||
bio text [null]
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
|
||||
Note: 'Users table'
|
||||
|
||||
indexes {
|
||||
email [name: 'idx_users_email']
|
||||
}
|
||||
}
|
||||
|
||||
Table posts {
|
||||
id bigserial [pk, increment]
|
||||
user_id bigint [not null]
|
||||
title varchar(200) [not null]
|
||||
content text [null]
|
||||
created_at timestamp [default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'idx_posts_user_id']
|
||||
(user_id, created_at) [name: 'idx_posts_user_created']
|
||||
}
|
||||
}
|
||||
|
||||
Ref: posts.user_id > users.id [delete: cascade, update: no action]
|
||||
```
|
||||
|
||||
## DBML Features
|
||||
|
||||
### Table Definitions
|
||||
```dbml
|
||||
Table table_name {
|
||||
column_name type [attributes]
|
||||
}
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
- `pk` - Primary key
|
||||
- `increment` - Auto-increment
|
||||
- `not null` - NOT NULL constraint
|
||||
- `null` - Nullable (explicit)
|
||||
- `unique` - Unique constraint
|
||||
- `default: value` - Default value
|
||||
- `note: 'text'` - Column note
|
||||
|
||||
### Relationships
|
||||
```dbml
|
||||
Ref: table1.column > table2.column
|
||||
Ref: table1.column < table2.column
|
||||
Ref: table1.column - table2.column
|
||||
```
|
||||
|
||||
Relationship types:
|
||||
- `>` - Many-to-one
|
||||
- `<` - One-to-many
|
||||
- `-` - One-to-one
|
||||
|
||||
Relationship actions:
|
||||
```dbml
|
||||
Ref: posts.user_id > users.id [delete: cascade, update: restrict]
|
||||
```
|
||||
|
||||
### Indexes
|
||||
```dbml
|
||||
indexes {
|
||||
column_name
|
||||
(column1, column2) [name: 'idx_name', unique]
|
||||
}
|
||||
```
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | DBML Type |
|
||||
|----------|-----------|
|
||||
| bigint | bigint |
|
||||
| integer | int |
|
||||
| varchar(n) | varchar(n) |
|
||||
| text | text |
|
||||
| boolean | boolean |
|
||||
| timestamp | timestamp |
|
||||
| date | date |
|
||||
| json | json |
|
||||
| uuid | uuid |
|
||||
|
||||
## Notes
|
||||
|
||||
- DBML is designed for database visualization
|
||||
- Can be imported into dbdiagram.io
|
||||
- Human-readable format
|
||||
- Schema names can be included in table names
|
||||
- Comments and notes are preserved
|
||||
- Ideal for documentation and sharing designs
|
||||
111
pkg/writers/dctx/README.md
Normal file
111
pkg/writers/dctx/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# DCTX Writer
|
||||
|
||||
Generates Clarion database dictionary (DCTX) files from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DCTX Writer converts RelSpec's internal database model representation into Clarion dictionary XML format, used by the Clarion development platform.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates DCTX XML format
|
||||
- Creates file (table) definitions
|
||||
- Defines fields (columns) with Clarion types
|
||||
- Includes keys (indexes)
|
||||
- Handles relationships
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/dctx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "database.dctx",
|
||||
}
|
||||
|
||||
writer := dctx.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate DCTX from PostgreSQL database (for Clarion migration)
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output dctx \
|
||||
--out-file app.dctx
|
||||
|
||||
# Convert GORM models to DCTX
|
||||
relspec --input gorm --in-file models.go --output dctx --out-file legacy.dctx
|
||||
|
||||
# Convert JSON schema to DCTX
|
||||
relspec --input json --in-file schema.json --output dctx --out-file database.dctx
|
||||
```
|
||||
|
||||
## Type Mapping
|
||||
|
||||
Converts standard SQL types to Clarion types:
|
||||
|
||||
| SQL Type | Clarion Type | Notes |
|
||||
|----------|--------------|-------|
|
||||
| VARCHAR(n) | STRING(n) | Fixed-length string |
|
||||
| TEXT | STRING | Variable length |
|
||||
| INTEGER | LONG | 32-bit integer |
|
||||
| BIGINT | DECIMAL(20,0) | Large integer |
|
||||
| SMALLINT | SHORT | 16-bit integer |
|
||||
| NUMERIC(p,s) | DECIMAL(p,s) | Decimal number |
|
||||
| REAL, FLOAT | REAL | Floating point |
|
||||
| BOOLEAN | BYTE | 0/1 value |
|
||||
| DATE | DATE | Date field |
|
||||
| TIME | TIME | Time field |
|
||||
| TIMESTAMP | LONG | Unix timestamp |
|
||||
|
||||
## DCTX Structure
|
||||
|
||||
DCTX files are XML-based with this structure:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<dictionary>
|
||||
<file name="USERS" driver="TOPSPEED">
|
||||
<record>
|
||||
<field name="ID" type="LONG" />
|
||||
<field name="USERNAME" type="STRING" bytes="50" />
|
||||
<field name="EMAIL" type="STRING" bytes="100" />
|
||||
</record>
|
||||
<key name="KEY_PRIMARY" primary="true">
|
||||
<field name="ID" />
|
||||
</key>
|
||||
</file>
|
||||
</dictionary>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- File definitions (equivalent to tables)
|
||||
- Field definitions with Clarion-specific types
|
||||
- Key definitions (primary and foreign)
|
||||
- Relationships between files
|
||||
- Driver specifications (TOPSPEED, SQL, etc.)
|
||||
|
||||
## Notes
|
||||
|
||||
- DCTX is specific to Clarion development
|
||||
- Useful for legacy system integration
|
||||
- Field names are typically uppercase in Clarion
|
||||
- Supports Clarion-specific attributes
|
||||
- Can be imported into Clarion IDE
|
||||
182
pkg/writers/drawdb/README.md
Normal file
182
pkg/writers/drawdb/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# DrawDB Writer
|
||||
|
||||
Generates DrawDB-compatible JSON files from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The DrawDB Writer converts RelSpec's internal database model representation into JSON format compatible with DrawDB, a free online database design tool.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates DrawDB JSON format
|
||||
- Creates table and field definitions
|
||||
- Defines relationships
|
||||
- Includes visual layout information
|
||||
- Preserves constraints and indexes
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "diagram.json",
|
||||
}
|
||||
|
||||
writer := drawdb.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate DrawDB diagram from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output drawdb \
|
||||
--out-file diagram.json
|
||||
|
||||
# Convert GORM models to DrawDB for visualization
|
||||
relspec --input gorm --in-file models.go --output drawdb --out-file design.json
|
||||
|
||||
# Convert JSON schema to DrawDB
|
||||
relspec --input json --in-file schema.json --output drawdb --out-file diagram.json
|
||||
```
|
||||
|
||||
## Generated JSON Example
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"database": "PostgreSQL",
|
||||
"tables": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "users",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"fields": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "id",
|
||||
"type": "BIGINT",
|
||||
"primary": true,
|
||||
"autoIncrement": true,
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "username",
|
||||
"type": "VARCHAR",
|
||||
"size": 50,
|
||||
"notNull": true,
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "email",
|
||||
"type": "VARCHAR",
|
||||
"size": 100,
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_users_email",
|
||||
"fields": ["email"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "posts",
|
||||
"x": 400,
|
||||
"y": 100,
|
||||
"fields": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "id",
|
||||
"type": "BIGINT",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "user_id",
|
||||
"type": "BIGINT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "title",
|
||||
"type": "VARCHAR",
|
||||
"size": 200,
|
||||
"notNull": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"id": "1",
|
||||
"source": "2",
|
||||
"target": "1",
|
||||
"sourceField": "user_id",
|
||||
"targetField": "id",
|
||||
"type": "many-to-one",
|
||||
"onDelete": "CASCADE"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## DrawDB Features
|
||||
|
||||
### Table Properties
|
||||
- `id` - Unique table identifier
|
||||
- `name` - Table name
|
||||
- `x`, `y` - Position in diagram
|
||||
- `fields` - Array of field definitions
|
||||
- `indexes` - Array of index definitions
|
||||
|
||||
### Field Properties
|
||||
- `id` - Unique field identifier
|
||||
- `name` - Field name
|
||||
- `type` - Data type (BIGINT, VARCHAR, etc.)
|
||||
- `size` - Length for string types
|
||||
- `primary` - Primary key flag
|
||||
- `notNull` - NOT NULL constraint
|
||||
- `unique` - Unique constraint
|
||||
- `autoIncrement` - Auto-increment flag
|
||||
- `default` - Default value
|
||||
|
||||
### Relationship Properties
|
||||
- `id` - Unique relationship identifier
|
||||
- `source` - Source table ID
|
||||
- `target` - Target table ID
|
||||
- `sourceField` - Foreign key field
|
||||
- `targetField` - Referenced field
|
||||
- `type` - Relationship type (one-to-one, one-to-many, many-to-one)
|
||||
- `onDelete` - Delete action
|
||||
- `onUpdate` - Update action
|
||||
|
||||
## Notes
|
||||
|
||||
- DrawDB is available at drawdb.vercel.app
|
||||
- Generated files can be imported for visual editing
|
||||
- Visual positions (x, y) are auto-generated
|
||||
- Ideal for creating ERD diagrams
|
||||
- Supports modern database features
|
||||
- Free and open-source tool
|
||||
120
pkg/writers/drizzle/README.md
Normal file
120
pkg/writers/drizzle/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Drizzle Writer
|
||||
|
||||
Generates TypeScript/JavaScript files with Drizzle ORM schema definitions from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Drizzle Writer converts RelSpec's internal database model representation into TypeScript source code with Drizzle ORM schema definitions, including tables, columns, relationships, and constraints.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates Drizzle-compatible TypeScript schema
|
||||
- Supports PostgreSQL and MySQL schemas
|
||||
- Creates table definitions with proper column types
|
||||
- Generates relationship definitions
|
||||
- Handles constraints and indexes
|
||||
- Outputs formatted TypeScript code
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/drizzle"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.ts",
|
||||
Metadata: map[string]interface{}{
|
||||
"database_type": "postgresql", // or "mysql"
|
||||
},
|
||||
}
|
||||
|
||||
writer := drizzle.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate Drizzle schema from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output drizzle \
|
||||
--out-file schema.ts
|
||||
|
||||
# Convert GORM models to Drizzle
|
||||
relspec --input gorm --in-file models.go --output drizzle --out-file schema.ts
|
||||
|
||||
# Convert JSON schema to Drizzle
|
||||
relspec --input json --in-file schema.json --output drizzle --out-file db/schema.ts
|
||||
```
|
||||
|
||||
## Generated Code Example
|
||||
|
||||
```typescript
|
||||
import { pgTable, serial, varchar, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
username: varchar('username', { length: 50 }).notNull().unique(),
|
||||
email: varchar('email', { length: 100 }).notNull(),
|
||||
bio: text('bio'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const posts = pgTable('posts', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
content: text('content'),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
posts: many(posts),
|
||||
}));
|
||||
|
||||
export const postsRelations = relations(posts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [posts.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Supported Column Types
|
||||
|
||||
### PostgreSQL
|
||||
- `serial`, `bigserial` - Auto-increment integers
|
||||
- `integer`, `bigint`, `smallint` - Integer types
|
||||
- `varchar`, `text` - String types
|
||||
- `boolean` - Boolean
|
||||
- `timestamp`, `date`, `time` - Date/time types
|
||||
- `json`, `jsonb` - JSON types
|
||||
- `uuid` - UUID type
|
||||
|
||||
### MySQL
|
||||
- `int`, `bigint`, `smallint` - Integer types
|
||||
- `varchar`, `text` - String types
|
||||
- `boolean` - Boolean
|
||||
- `datetime`, `timestamp` - Date/time types
|
||||
- `json` - JSON type
|
||||
|
||||
## Notes
|
||||
|
||||
- Table names and column names are preserved as-is
|
||||
- Relationships are generated as separate relation definitions
|
||||
- Constraint actions (CASCADE, etc.) are included in references
|
||||
- Schema names other than 'public' are supported
|
||||
- Output is formatted TypeScript code
|
||||
221
pkg/writers/drizzle/template_data.go
Normal file
221
pkg/writers/drizzle/template_data.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// TemplateData represents the data passed to the template for code generation
|
||||
type TemplateData struct {
|
||||
Imports []string
|
||||
Enums []*EnumData
|
||||
Tables []*TableData
|
||||
}
|
||||
|
||||
// EnumData represents an enum in the schema
|
||||
type EnumData struct {
|
||||
Name string // Enum name (PascalCase)
|
||||
VarName string // Variable name for the enum (camelCase)
|
||||
Values []string // Enum values
|
||||
ValuesStr string // Comma-separated quoted values for pgEnum()
|
||||
TypeUnion string // TypeScript union type (e.g., "'admin' | 'user' | 'guest'")
|
||||
SchemaName string // Schema name
|
||||
}
|
||||
|
||||
// TableData represents a table in the template
|
||||
type TableData struct {
|
||||
Name string // Table variable name (camelCase, e.g., users)
|
||||
TableName string // Actual database table name (e.g., users)
|
||||
TypeName string // TypeScript type name (PascalCase, e.g., Users)
|
||||
Columns []*ColumnData // Column definitions
|
||||
Indexes []*IndexData // Index definitions
|
||||
Comment string // Table comment
|
||||
SchemaName string // Schema name
|
||||
NeedsSQLTag bool // Whether we need to import 'sql' from drizzle-orm
|
||||
IndexColumnFields []string // Column field names used in indexes (for destructuring)
|
||||
}
|
||||
|
||||
// ColumnData represents a column in a table
|
||||
type ColumnData struct {
|
||||
Name string // Column name in database
|
||||
FieldName string // Field name in TypeScript (camelCase)
|
||||
DrizzleChain string // Complete Drizzle column chain (e.g., "integer('id').primaryKey()")
|
||||
TypeScriptType string // TypeScript type for interface (e.g., "string", "number | null")
|
||||
IsForeignKey bool // Whether this is a foreign key
|
||||
ReferencesLine string // The .references() line if FK
|
||||
Comment string // Column comment
|
||||
}
|
||||
|
||||
// IndexData represents an index definition
|
||||
type IndexData struct {
|
||||
Name string // Index name
|
||||
Columns []string // Column names
|
||||
IsUnique bool // Whether it's a unique index
|
||||
Definition string // Complete index definition line
|
||||
}
|
||||
|
||||
// NewTemplateData creates a new TemplateData
|
||||
func NewTemplateData() *TemplateData {
|
||||
return &TemplateData{
|
||||
Imports: make([]string, 0),
|
||||
Enums: make([]*EnumData, 0),
|
||||
Tables: make([]*TableData, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddImport adds an import to the template data (deduplicates automatically)
|
||||
func (td *TemplateData) AddImport(importLine string) {
|
||||
// Check if already exists
|
||||
for _, imp := range td.Imports {
|
||||
if imp == importLine {
|
||||
return
|
||||
}
|
||||
}
|
||||
td.Imports = append(td.Imports, importLine)
|
||||
}
|
||||
|
||||
// AddEnum adds an enum to the template data
|
||||
func (td *TemplateData) AddEnum(enum *EnumData) {
|
||||
td.Enums = append(td.Enums, enum)
|
||||
}
|
||||
|
||||
// AddTable adds a table to the template data
|
||||
func (td *TemplateData) AddTable(table *TableData) {
|
||||
td.Tables = append(td.Tables, table)
|
||||
}
|
||||
|
||||
// FinalizeImports sorts imports
|
||||
func (td *TemplateData) FinalizeImports() {
|
||||
sort.Strings(td.Imports)
|
||||
}
|
||||
|
||||
// NewEnumData creates EnumData from a models.Enum
|
||||
func NewEnumData(enum *models.Enum, tm *TypeMapper) *EnumData {
|
||||
// Keep enum name as-is (it should already be PascalCase from the source)
|
||||
enumName := enum.Name
|
||||
// Variable name is camelCase version
|
||||
varName := tm.ToCamelCase(enum.Name)
|
||||
|
||||
// Format values as comma-separated quoted strings for pgEnum()
|
||||
quotedValues := make([]string, len(enum.Values))
|
||||
for i, v := range enum.Values {
|
||||
quotedValues[i] = "'" + v + "'"
|
||||
}
|
||||
valuesStr := ""
|
||||
for i, qv := range quotedValues {
|
||||
if i > 0 {
|
||||
valuesStr += ", "
|
||||
}
|
||||
valuesStr += qv
|
||||
}
|
||||
|
||||
// Build TypeScript union type (e.g., "'admin' | 'user' | 'guest'")
|
||||
typeUnion := ""
|
||||
for i, qv := range quotedValues {
|
||||
if i > 0 {
|
||||
typeUnion += " | "
|
||||
}
|
||||
typeUnion += qv
|
||||
}
|
||||
|
||||
return &EnumData{
|
||||
Name: enumName,
|
||||
VarName: varName,
|
||||
Values: enum.Values,
|
||||
ValuesStr: valuesStr,
|
||||
TypeUnion: typeUnion,
|
||||
SchemaName: enum.Schema,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTableData creates TableData from a models.Table
|
||||
func NewTableData(table *models.Table, tm *TypeMapper) *TableData {
|
||||
tableName := tm.ToCamelCase(table.Name)
|
||||
typeName := tm.ToPascalCase(table.Name)
|
||||
|
||||
return &TableData{
|
||||
Name: tableName,
|
||||
TableName: table.Name,
|
||||
TypeName: typeName,
|
||||
Columns: make([]*ColumnData, 0),
|
||||
Indexes: make([]*IndexData, 0),
|
||||
Comment: formatComment(table.Description, table.Comment),
|
||||
SchemaName: table.Schema,
|
||||
}
|
||||
}
|
||||
|
||||
// AddColumn adds a column to the table data
|
||||
func (td *TableData) AddColumn(col *ColumnData) {
|
||||
td.Columns = append(td.Columns, col)
|
||||
}
|
||||
|
||||
// AddIndex adds an index to the table data
|
||||
func (td *TableData) AddIndex(idx *IndexData) {
|
||||
td.Indexes = append(td.Indexes, idx)
|
||||
}
|
||||
|
||||
// NewColumnData creates ColumnData from a models.Column
|
||||
func NewColumnData(col *models.Column, table *models.Table, tm *TypeMapper, isEnum bool) *ColumnData {
|
||||
fieldName := tm.ToCamelCase(col.Name)
|
||||
drizzleChain := tm.BuildColumnChain(col, table, isEnum)
|
||||
|
||||
return &ColumnData{
|
||||
Name: col.Name,
|
||||
FieldName: fieldName,
|
||||
DrizzleChain: drizzleChain,
|
||||
Comment: formatComment(col.Description, col.Comment),
|
||||
}
|
||||
}
|
||||
|
||||
// NewIndexData creates IndexData from a models.Index
|
||||
func NewIndexData(index *models.Index, tableVar string, tm *TypeMapper) *IndexData {
|
||||
indexName := tm.ToCamelCase(index.Name) + "Idx"
|
||||
|
||||
// Build column references as field names (will be used with destructuring)
|
||||
colRefs := make([]string, len(index.Columns))
|
||||
for i, colName := range index.Columns {
|
||||
// Use just the field name for destructured parameters
|
||||
colRefs[i] = tm.ToCamelCase(colName)
|
||||
}
|
||||
|
||||
// Build the complete definition
|
||||
// Example: index('email_idx').on(email)
|
||||
// or: uniqueIndex('unique_email_idx').on(email)
|
||||
definition := ""
|
||||
if index.Unique {
|
||||
definition = "uniqueIndex('" + index.Name + "').on(" + joinStrings(colRefs, ", ") + ")"
|
||||
} else {
|
||||
definition = "index('" + index.Name + "').on(" + joinStrings(colRefs, ", ") + ")"
|
||||
}
|
||||
|
||||
return &IndexData{
|
||||
Name: indexName,
|
||||
Columns: index.Columns,
|
||||
IsUnique: index.Unique,
|
||||
Definition: definition,
|
||||
}
|
||||
}
|
||||
|
||||
// formatComment combines description and comment into a single comment string
|
||||
func formatComment(description, comment string) string {
|
||||
if description != "" && comment != "" {
|
||||
return description + " - " + comment
|
||||
}
|
||||
if description != "" {
|
||||
return description
|
||||
}
|
||||
return comment
|
||||
}
|
||||
|
||||
// joinStrings joins a slice of strings with a separator
|
||||
func joinStrings(strs []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range strs {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
64
pkg/writers/drizzle/templates.go
Normal file
64
pkg/writers/drizzle/templates.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// schemaTemplate defines the template for generating Drizzle schemas
|
||||
const schemaTemplate = `// Code generated by relspecgo. DO NOT EDIT.
|
||||
{{range .Imports}}{{.}}
|
||||
{{end}}
|
||||
{{if .Enums}}
|
||||
// Enums
|
||||
{{range .Enums}}export const {{.VarName}} = pgEnum('{{.Name}}', [{{.ValuesStr}}]);
|
||||
export type {{.Name}} = {{.TypeUnion}};
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{range .Tables}}// Table: {{.TableName}}{{if .Comment}} - {{.Comment}}{{end}}
|
||||
export interface {{.TypeName}} {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{$col.FieldName}}: {{$col.TypeScriptType}};{{if $col.Comment}} // {{$col.Comment}}{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
export const {{.Name}} = pgTable('{{.TableName}}', {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{$col.FieldName}}: {{$col.DrizzleChain}},{{if $col.Comment}} // {{$col.Comment}}{{end}}
|
||||
{{- end}}
|
||||
}{{if .Indexes}}{{if .IndexColumnFields}}, ({ {{range $i, $field := .IndexColumnFields}}{{if $i}}, {{end}}{{$field}}{{end}} }) => [{{else}}, (table) => [{{end}}
|
||||
{{- range $i, $idx := .Indexes}}
|
||||
{{$idx.Definition}},
|
||||
{{- end}}
|
||||
]{{end}});
|
||||
|
||||
export type New{{.TypeName}} = typeof {{.Name}}.$inferInsert;
|
||||
{{end}}`
|
||||
|
||||
// Templates holds the parsed templates
|
||||
type Templates struct {
|
||||
schemaTmpl *template.Template
|
||||
}
|
||||
|
||||
// NewTemplates creates and parses the templates
|
||||
func NewTemplates() (*Templates, error) {
|
||||
schemaTmpl, err := template.New("schema").Parse(schemaTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Templates{
|
||||
schemaTmpl: schemaTmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateCode executes the template with the given data
|
||||
func (t *Templates) GenerateCode(data *TemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := t.schemaTmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
318
pkg/writers/drizzle/type_mapper.go
Normal file
318
pkg/writers/drizzle/type_mapper.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// TypeMapper handles SQL to Drizzle type conversions
|
||||
type TypeMapper struct{}
|
||||
|
||||
// NewTypeMapper creates a new TypeMapper instance
|
||||
func NewTypeMapper() *TypeMapper {
|
||||
return &TypeMapper{}
|
||||
}
|
||||
|
||||
// SQLTypeToDrizzle converts SQL types to Drizzle column type functions
|
||||
// Returns the Drizzle column constructor (e.g., "integer", "varchar", "text")
|
||||
func (tm *TypeMapper) SQLTypeToDrizzle(sqlType string) string {
|
||||
sqlTypeLower := strings.ToLower(sqlType)
|
||||
|
||||
// PostgreSQL type mapping to Drizzle
|
||||
typeMap := map[string]string{
|
||||
// Integer types
|
||||
"integer": "integer",
|
||||
"int": "integer",
|
||||
"int4": "integer",
|
||||
"smallint": "smallint",
|
||||
"int2": "smallint",
|
||||
"bigint": "bigint",
|
||||
"int8": "bigint",
|
||||
|
||||
// Serial types
|
||||
"serial": "serial",
|
||||
"serial4": "serial",
|
||||
"smallserial": "smallserial",
|
||||
"serial2": "smallserial",
|
||||
"bigserial": "bigserial",
|
||||
"serial8": "bigserial",
|
||||
|
||||
// Numeric types
|
||||
"numeric": "numeric",
|
||||
"decimal": "numeric",
|
||||
"real": "real",
|
||||
"float4": "real",
|
||||
"double precision": "doublePrecision",
|
||||
"float": "doublePrecision",
|
||||
"float8": "doublePrecision",
|
||||
|
||||
// Character types
|
||||
"text": "text",
|
||||
"varchar": "varchar",
|
||||
"character varying": "varchar",
|
||||
"char": "char",
|
||||
"character": "char",
|
||||
|
||||
// Boolean
|
||||
"boolean": "boolean",
|
||||
"bool": "boolean",
|
||||
|
||||
// Binary
|
||||
"bytea": "bytea",
|
||||
|
||||
// JSON types
|
||||
"json": "json",
|
||||
"jsonb": "jsonb",
|
||||
|
||||
// Date/Time types
|
||||
"time": "time",
|
||||
"timetz": "time",
|
||||
"timestamp": "timestamp",
|
||||
"timestamptz": "timestamp",
|
||||
"date": "date",
|
||||
"interval": "interval",
|
||||
|
||||
// UUID
|
||||
"uuid": "uuid",
|
||||
|
||||
// Geometric types
|
||||
"point": "point",
|
||||
"line": "line",
|
||||
}
|
||||
|
||||
// Check for exact match first
|
||||
if drizzleType, ok := typeMap[sqlTypeLower]; ok {
|
||||
return drizzleType
|
||||
}
|
||||
|
||||
// Check for partial matches (e.g., "varchar(255)" -> "varchar")
|
||||
for sqlPattern, drizzleType := range typeMap {
|
||||
if strings.HasPrefix(sqlTypeLower, sqlPattern) {
|
||||
return drizzleType
|
||||
}
|
||||
}
|
||||
|
||||
// Default to text for unknown types
|
||||
return "text"
|
||||
}
|
||||
|
||||
// BuildColumnChain builds the complete column definition chain for Drizzle
|
||||
// Example: integer('id').primaryKey().notNull()
|
||||
func (tm *TypeMapper) BuildColumnChain(col *models.Column, table *models.Table, isEnum bool) string {
|
||||
var parts []string
|
||||
|
||||
// Determine Drizzle column type
|
||||
var drizzleType string
|
||||
if isEnum {
|
||||
// For enum types, use the type name directly
|
||||
drizzleType = fmt.Sprintf("pgEnum('%s')", col.Type)
|
||||
} else {
|
||||
drizzleType = tm.SQLTypeToDrizzle(col.Type)
|
||||
}
|
||||
|
||||
// Start with column type and name
|
||||
// Note: column name is passed as first argument to the column constructor
|
||||
base := fmt.Sprintf("%s('%s')", drizzleType, col.Name)
|
||||
parts = append(parts, base)
|
||||
|
||||
// Add column modifiers in order
|
||||
modifiers := tm.buildColumnModifiers(col, table)
|
||||
if len(modifiers) > 0 {
|
||||
parts = append(parts, modifiers...)
|
||||
}
|
||||
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// buildColumnModifiers builds an array of method calls for column modifiers
|
||||
func (tm *TypeMapper) buildColumnModifiers(col *models.Column, table *models.Table) []string {
|
||||
var modifiers []string
|
||||
|
||||
// Primary key
|
||||
if col.IsPrimaryKey {
|
||||
modifiers = append(modifiers, "primaryKey()")
|
||||
}
|
||||
|
||||
// Not null constraint
|
||||
if col.NotNull && !col.IsPrimaryKey {
|
||||
modifiers = append(modifiers, "notNull()")
|
||||
}
|
||||
|
||||
// Unique constraint (check if there's a single-column unique constraint)
|
||||
if tm.hasUniqueConstraint(col.Name, table) {
|
||||
modifiers = append(modifiers, "unique()")
|
||||
}
|
||||
|
||||
// Default value
|
||||
if col.AutoIncrement {
|
||||
// For auto-increment, use generatedAlwaysAsIdentity()
|
||||
modifiers = append(modifiers, "generatedAlwaysAsIdentity()")
|
||||
} else if col.Default != nil {
|
||||
defaultValue := tm.formatDefaultValue(col.Default)
|
||||
if defaultValue != "" {
|
||||
modifiers = append(modifiers, fmt.Sprintf("default(%s)", defaultValue))
|
||||
}
|
||||
}
|
||||
|
||||
return modifiers
|
||||
}
|
||||
|
||||
// formatDefaultValue formats a default value for Drizzle
|
||||
func (tm *TypeMapper) formatDefaultValue(defaultValue any) string {
|
||||
switch v := defaultValue.(type) {
|
||||
case string:
|
||||
if v == "now()" || v == "CURRENT_TIMESTAMP" {
|
||||
return "sql`now()`"
|
||||
} else if v == "gen_random_uuid()" || strings.Contains(strings.ToLower(v), "uuid") {
|
||||
return "sql`gen_random_uuid()`"
|
||||
} else {
|
||||
// Try to parse as number first
|
||||
// Check if it's a numeric string that should be a number
|
||||
if isNumericString(v) {
|
||||
return v
|
||||
}
|
||||
// String literal
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(v, "'", "\\'"))
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int, int64, int32, int16, int8:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case float32, float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// isNumericString checks if a string represents a number
|
||||
func isNumericString(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Simple check for numeric strings
|
||||
for i, c := range s {
|
||||
if i == 0 && c == '-' {
|
||||
continue // Allow negative sign at start
|
||||
}
|
||||
if c < '0' || c > '9' {
|
||||
if c != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hasUniqueConstraint checks if a column has a unique constraint
|
||||
func (tm *TypeMapper) hasUniqueConstraint(colName string, table *models.Table) bool {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint &&
|
||||
len(constraint.Columns) == 1 &&
|
||||
constraint.Columns[0] == colName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildReferencesChain builds the .references() chain for foreign key columns
|
||||
func (tm *TypeMapper) BuildReferencesChain(fk *models.Constraint, referencedTable string) string {
|
||||
// Example: .references(() => users.id)
|
||||
if len(fk.ReferencedColumns) > 0 {
|
||||
// Use the referenced table variable name (camelCase)
|
||||
refTableVar := tm.ToCamelCase(referencedTable)
|
||||
refColumn := fk.ReferencedColumns[0]
|
||||
return fmt.Sprintf("references(() => %s.%s)", refTableVar, refColumn)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ToCamelCase converts snake_case or PascalCase to camelCase
|
||||
func (tm *TypeMapper) ToCamelCase(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Check if it's snake_case
|
||||
if strings.Contains(s, "_") {
|
||||
parts := strings.Split(s, "_")
|
||||
if len(parts) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
// First part stays lowercase
|
||||
result := strings.ToLower(parts[0])
|
||||
|
||||
// Capitalize first letter of remaining parts
|
||||
for i := 1; i < len(parts); i++ {
|
||||
if len(parts[i]) > 0 {
|
||||
result += strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Otherwise, assume it's PascalCase - just lowercase the first letter
|
||||
return strings.ToLower(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// ToPascalCase converts snake_case to PascalCase
|
||||
func (tm *TypeMapper) ToPascalCase(s string) string {
|
||||
parts := strings.Split(s, "_")
|
||||
var result string
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) > 0 {
|
||||
result += strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// DrizzleTypeToTypeScript converts Drizzle column types to TypeScript types
|
||||
func (tm *TypeMapper) DrizzleTypeToTypeScript(drizzleType string, isEnum bool, enumName string) string {
|
||||
if isEnum {
|
||||
return enumName
|
||||
}
|
||||
|
||||
typeMap := map[string]string{
|
||||
"integer": "number",
|
||||
"bigint": "number",
|
||||
"smallint": "number",
|
||||
"serial": "number",
|
||||
"bigserial": "number",
|
||||
"smallserial": "number",
|
||||
"numeric": "number",
|
||||
"real": "number",
|
||||
"doublePrecision": "number",
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"boolean": "boolean",
|
||||
"bytea": "Buffer",
|
||||
"json": "any",
|
||||
"jsonb": "any",
|
||||
"timestamp": "Date",
|
||||
"date": "Date",
|
||||
"time": "Date",
|
||||
"interval": "string",
|
||||
"uuid": "string",
|
||||
"point": "{ x: number; y: number }",
|
||||
"line": "{ a: number; b: number; c: number }",
|
||||
}
|
||||
|
||||
if tsType, ok := typeMap[drizzleType]; ok {
|
||||
return tsType
|
||||
}
|
||||
|
||||
// Default to any for unknown types
|
||||
return "any"
|
||||
}
|
||||
543
pkg/writers/drizzle/writer.go
Normal file
543
pkg/writers/drizzle/writer.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// Writer implements the writers.Writer interface for Drizzle ORM
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
typeMapper *TypeMapper
|
||||
templates *Templates
|
||||
}
|
||||
|
||||
// NewWriter creates a new Drizzle writer with the given options
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
w := &Writer{
|
||||
options: options,
|
||||
typeMapper: NewTypeMapper(),
|
||||
}
|
||||
|
||||
// Initialize templates
|
||||
tmpl, err := NewTemplates()
|
||||
if err != nil {
|
||||
// Should not happen with embedded templates
|
||||
panic(fmt.Sprintf("failed to initialize templates: %v", err))
|
||||
}
|
||||
w.templates = tmpl
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// WriteDatabase writes a complete database as Drizzle schema
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Check if multi-file mode is enabled
|
||||
multiFile := w.shouldUseMultiFile()
|
||||
|
||||
if multiFile {
|
||||
return w.writeMultiFile(db)
|
||||
}
|
||||
|
||||
return w.writeSingleFile(db)
|
||||
}
|
||||
|
||||
// WriteSchema writes a schema as Drizzle schema
|
||||
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 as a Drizzle schema
|
||||
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)
|
||||
}
|
||||
|
||||
// writeSingleFile writes all tables to a single file
|
||||
func (w *Writer) writeSingleFile(db *models.Database) error {
|
||||
templateData := NewTemplateData()
|
||||
|
||||
// Build enum map for quick lookup
|
||||
enumMap := w.buildEnumMap(db)
|
||||
|
||||
// Process all schemas
|
||||
for _, schema := range db.Schemas {
|
||||
// Add enums
|
||||
for _, enum := range schema.Enums {
|
||||
enumData := NewEnumData(enum, w.typeMapper)
|
||||
templateData.AddEnum(enumData)
|
||||
}
|
||||
|
||||
// Add tables
|
||||
for _, table := range schema.Tables {
|
||||
tableData := w.buildTableData(table, schema, db, enumMap)
|
||||
templateData.AddTable(tableData)
|
||||
}
|
||||
}
|
||||
|
||||
// Add imports
|
||||
w.addImports(templateData, db)
|
||||
|
||||
// Finalize imports
|
||||
templateData.FinalizeImports()
|
||||
|
||||
// Generate code
|
||||
code, err := w.templates.GenerateCode(templateData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate code: %w", err)
|
||||
}
|
||||
|
||||
// Write output
|
||||
return w.writeOutput(code)
|
||||
}
|
||||
|
||||
// writeMultiFile writes each table to a separate file
|
||||
func (w *Writer) writeMultiFile(db *models.Database) error {
|
||||
// Ensure output path is a directory
|
||||
if w.options.OutputPath == "" {
|
||||
return fmt.Errorf("output path is required for multi-file mode")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(w.options.OutputPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Build enum map for quick lookup
|
||||
enumMap := w.buildEnumMap(db)
|
||||
|
||||
// Process all schemas
|
||||
for _, schema := range db.Schemas {
|
||||
// Write enums file if there are any
|
||||
if len(schema.Enums) > 0 {
|
||||
if err := w.writeEnumsFile(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write each table to a separate file
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeTableFile(table, schema, db, enumMap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEnumsFile writes all enums to a separate file
|
||||
func (w *Writer) writeEnumsFile(schema *models.Schema) error {
|
||||
templateData := NewTemplateData()
|
||||
|
||||
// Add enums
|
||||
for _, enum := range schema.Enums {
|
||||
enumData := NewEnumData(enum, w.typeMapper)
|
||||
templateData.AddEnum(enumData)
|
||||
}
|
||||
|
||||
// Add imports for enums
|
||||
templateData.AddImport("import { pgEnum } from 'drizzle-orm/pg-core';")
|
||||
|
||||
// Generate code
|
||||
code, err := w.templates.GenerateCode(templateData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate enums code: %w", err)
|
||||
}
|
||||
|
||||
// Write to enums.ts file
|
||||
filename := filepath.Join(w.options.OutputPath, "enums.ts")
|
||||
return os.WriteFile(filename, []byte(code), 0644)
|
||||
}
|
||||
|
||||
// writeTableFile writes a single table to its own file
|
||||
func (w *Writer) writeTableFile(table *models.Table, schema *models.Schema, db *models.Database, enumMap map[string]bool) error {
|
||||
templateData := NewTemplateData()
|
||||
|
||||
// Build table data
|
||||
tableData := w.buildTableData(table, schema, db, enumMap)
|
||||
templateData.AddTable(tableData)
|
||||
|
||||
// Add imports
|
||||
w.addImports(templateData, db)
|
||||
|
||||
// If there are enums, add import from enums file
|
||||
if len(schema.Enums) > 0 && w.tableUsesEnum(table, enumMap) {
|
||||
// Import enum definitions from enums.ts
|
||||
enumNames := w.getTableEnumNames(table, schema, enumMap)
|
||||
if len(enumNames) > 0 {
|
||||
importLine := fmt.Sprintf("import { %s } from './enums';", strings.Join(enumNames, ", "))
|
||||
templateData.AddImport(importLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize imports
|
||||
templateData.FinalizeImports()
|
||||
|
||||
// Generate code
|
||||
code, err := w.templates.GenerateCode(templateData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate code for table %s: %w", table.Name, err)
|
||||
}
|
||||
|
||||
// Generate filename: {tableName}.ts
|
||||
filename := filepath.Join(w.options.OutputPath, table.Name+".ts")
|
||||
return os.WriteFile(filename, []byte(code), 0644)
|
||||
}
|
||||
|
||||
// buildTableData builds TableData from a models.Table
|
||||
func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *models.Database, enumMap map[string]bool) *TableData {
|
||||
tableData := NewTableData(table, w.typeMapper)
|
||||
|
||||
// Add columns
|
||||
for _, colName := range w.getSortedColumnNames(table) {
|
||||
col := table.Columns[colName]
|
||||
|
||||
// Check if this column uses an enum
|
||||
isEnum := enumMap[col.Type]
|
||||
|
||||
columnData := NewColumnData(col, table, w.typeMapper, isEnum)
|
||||
|
||||
// Set TypeScript type
|
||||
drizzleType := w.typeMapper.SQLTypeToDrizzle(col.Type)
|
||||
enumName := ""
|
||||
if isEnum {
|
||||
// For enums, use the enum type name
|
||||
enumName = col.Type
|
||||
}
|
||||
baseType := w.typeMapper.DrizzleTypeToTypeScript(drizzleType, isEnum, enumName)
|
||||
|
||||
// Add null union if column is nullable
|
||||
if !col.NotNull && !col.IsPrimaryKey {
|
||||
columnData.TypeScriptType = baseType + " | null"
|
||||
} else {
|
||||
columnData.TypeScriptType = baseType
|
||||
}
|
||||
|
||||
// Check if this column is a foreign key
|
||||
if fk := w.getForeignKeyForColumn(col.Name, table); fk != nil {
|
||||
columnData.IsForeignKey = true
|
||||
refTableName := fk.ReferencedTable
|
||||
refChain := w.typeMapper.BuildReferencesChain(fk, refTableName)
|
||||
if refChain != "" {
|
||||
columnData.ReferencesLine = "." + refChain
|
||||
// Append to the drizzle chain
|
||||
columnData.DrizzleChain += columnData.ReferencesLine
|
||||
}
|
||||
}
|
||||
|
||||
tableData.AddColumn(columnData)
|
||||
}
|
||||
|
||||
// Collect all column field names that are used in indexes
|
||||
indexColumnFields := make(map[string]bool)
|
||||
|
||||
// Add indexes (excluding single-column unique indexes, which are handled inline)
|
||||
for _, index := range table.Indexes {
|
||||
// Skip single-column unique indexes (handled by .unique() modifier)
|
||||
if index.Unique && len(index.Columns) == 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track which columns are used in indexes
|
||||
for _, colName := range index.Columns {
|
||||
// Find the field name for this column
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
fieldName := w.typeMapper.ToCamelCase(col.Name)
|
||||
indexColumnFields[fieldName] = true
|
||||
}
|
||||
}
|
||||
|
||||
indexData := NewIndexData(index, tableData.Name, w.typeMapper)
|
||||
tableData.AddIndex(indexData)
|
||||
}
|
||||
|
||||
// Add multi-column unique constraints as unique indexes
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
|
||||
// Create a unique index for this constraint
|
||||
indexData := &IndexData{
|
||||
Name: w.typeMapper.ToCamelCase(constraint.Name) + "Idx",
|
||||
Columns: constraint.Columns,
|
||||
IsUnique: true,
|
||||
}
|
||||
|
||||
// Track which columns are used in indexes
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
fieldName := w.typeMapper.ToCamelCase(col.Name)
|
||||
indexColumnFields[fieldName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build column references as field names (for destructuring)
|
||||
colRefs := make([]string, len(constraint.Columns))
|
||||
for i, colName := range constraint.Columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
colRefs[i] = w.typeMapper.ToCamelCase(col.Name)
|
||||
} else {
|
||||
colRefs[i] = w.typeMapper.ToCamelCase(colName)
|
||||
}
|
||||
}
|
||||
|
||||
indexData.Definition = "uniqueIndex('" + constraint.Name + "').on(" + joinStrings(colRefs, ", ") + ")"
|
||||
tableData.AddIndex(indexData)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert index column fields map to sorted slice
|
||||
if len(indexColumnFields) > 0 {
|
||||
fields := make([]string, 0, len(indexColumnFields))
|
||||
for field := range indexColumnFields {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
// Sort for consistent output
|
||||
sortStrings(fields)
|
||||
tableData.IndexColumnFields = fields
|
||||
}
|
||||
|
||||
return tableData
|
||||
}
|
||||
|
||||
// sortStrings sorts a slice of strings in place
|
||||
func sortStrings(strs []string) {
|
||||
for i := 0; i < len(strs); i++ {
|
||||
for j := i + 1; j < len(strs); j++ {
|
||||
if strs[i] > strs[j] {
|
||||
strs[i], strs[j] = strs[j], strs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addImports adds the necessary imports to the template data
|
||||
func (w *Writer) addImports(templateData *TemplateData, db *models.Database) {
|
||||
// Determine which Drizzle imports we need
|
||||
needsPgTable := len(templateData.Tables) > 0
|
||||
needsPgEnum := len(templateData.Enums) > 0
|
||||
needsIndex := false
|
||||
needsUniqueIndex := false
|
||||
needsSQL := false
|
||||
|
||||
// Check what we need based on tables
|
||||
for _, table := range templateData.Tables {
|
||||
for _, index := range table.Indexes {
|
||||
if index.IsUnique {
|
||||
needsUniqueIndex = true
|
||||
} else {
|
||||
needsIndex = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any column uses SQL default values
|
||||
for _, col := range table.Columns {
|
||||
if strings.Contains(col.DrizzleChain, "sql`") {
|
||||
needsSQL = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the import statement
|
||||
imports := make([]string, 0)
|
||||
|
||||
if needsPgTable {
|
||||
imports = append(imports, "pgTable")
|
||||
}
|
||||
if needsPgEnum {
|
||||
imports = append(imports, "pgEnum")
|
||||
}
|
||||
|
||||
// Add column types - for now, add common ones
|
||||
// TODO: Could be optimized to only include used types
|
||||
columnTypes := []string{
|
||||
"integer", "bigint", "smallint",
|
||||
"serial", "bigserial", "smallserial",
|
||||
"text", "varchar", "char",
|
||||
"boolean", "numeric", "real", "doublePrecision",
|
||||
"timestamp", "date", "time", "interval",
|
||||
"json", "jsonb", "uuid", "bytea",
|
||||
}
|
||||
imports = append(imports, columnTypes...)
|
||||
|
||||
if needsIndex {
|
||||
imports = append(imports, "index")
|
||||
}
|
||||
if needsUniqueIndex {
|
||||
imports = append(imports, "uniqueIndex")
|
||||
}
|
||||
|
||||
importLine := "import { " + strings.Join(imports, ", ") + " } from 'drizzle-orm/pg-core';"
|
||||
templateData.AddImport(importLine)
|
||||
|
||||
// Add SQL import if needed
|
||||
if needsSQL {
|
||||
templateData.AddImport("import { sql } from 'drizzle-orm';")
|
||||
}
|
||||
}
|
||||
|
||||
// buildEnumMap builds a map of enum type names for quick lookup
|
||||
func (w *Writer) buildEnumMap(db *models.Database) map[string]bool {
|
||||
enumMap := make(map[string]bool)
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, enum := range schema.Enums {
|
||||
enumMap[enum.Name] = true
|
||||
// Also add lowercase version for case-insensitive lookup
|
||||
enumMap[strings.ToLower(enum.Name)] = true
|
||||
}
|
||||
}
|
||||
|
||||
return enumMap
|
||||
}
|
||||
|
||||
// tableUsesEnum checks if a table uses any enum types
|
||||
func (w *Writer) tableUsesEnum(table *models.Table, enumMap map[string]bool) bool {
|
||||
for _, col := range table.Columns {
|
||||
if enumMap[col.Type] || enumMap[strings.ToLower(col.Type)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getTableEnumNames returns the list of enum variable names used by a table
|
||||
func (w *Writer) getTableEnumNames(table *models.Table, schema *models.Schema, enumMap map[string]bool) []string {
|
||||
enumNames := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, col := range table.Columns {
|
||||
if enumMap[col.Type] || enumMap[strings.ToLower(col.Type)] {
|
||||
// Find the enum in schema
|
||||
for _, enum := range schema.Enums {
|
||||
if strings.EqualFold(enum.Name, col.Type) {
|
||||
varName := w.typeMapper.ToCamelCase(enum.Name)
|
||||
if !seen[varName] {
|
||||
enumNames = append(enumNames, varName)
|
||||
seen[varName] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enumNames
|
||||
}
|
||||
|
||||
// getSortedColumnNames returns column names sorted by sequence or name
|
||||
func (w *Writer) getSortedColumnNames(table *models.Table) []string {
|
||||
// Convert map to slice
|
||||
columns := make([]*models.Column, 0, len(table.Columns))
|
||||
for _, col := range table.Columns {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
// Sort by sequence, then by primary key, then by name
|
||||
// (Similar to GORM writer)
|
||||
sortColumns := func(i, j int) bool {
|
||||
// Sort by sequence if both have it
|
||||
if columns[i].Sequence > 0 && columns[j].Sequence > 0 {
|
||||
return columns[i].Sequence < columns[j].Sequence
|
||||
}
|
||||
|
||||
// Put primary keys first
|
||||
if columns[i].IsPrimaryKey != columns[j].IsPrimaryKey {
|
||||
return columns[i].IsPrimaryKey
|
||||
}
|
||||
|
||||
// Otherwise sort alphabetically
|
||||
return columns[i].Name < columns[j].Name
|
||||
}
|
||||
|
||||
// Create a custom sorter
|
||||
for i := 0; i < len(columns); i++ {
|
||||
for j := i + 1; j < len(columns); j++ {
|
||||
if !sortColumns(i, j) {
|
||||
columns[i], columns[j] = columns[j], columns[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract names
|
||||
names := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
names[i] = col.Name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// getForeignKeyForColumn returns the foreign key constraint for a column, if any
|
||||
func (w *Writer) getForeignKeyForColumn(columnName string, table *models.Table) *models.Constraint {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, col := range constraint.Columns {
|
||||
if col == columnName {
|
||||
return constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeOutput writes the content to file or stdout
|
||||
func (w *Writer) writeOutput(content string) error {
|
||||
if w.options.OutputPath != "" {
|
||||
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// Print to stdout
|
||||
fmt.Print(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
if w.options.Metadata != nil {
|
||||
if mf, ok := w.options.Metadata["multi_file"].(bool); ok {
|
||||
return mf
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect based on output path
|
||||
if w.options.OutputPath == "" {
|
||||
// No output path means stdout (single file)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with .ts (explicit file)
|
||||
if strings.HasSuffix(w.options.OutputPath, ".ts") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with directory separator
|
||||
if strings.HasSuffix(w.options.OutputPath, "/") || strings.HasSuffix(w.options.OutputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
info, err := os.Stat(w.options.OutputPath)
|
||||
if err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to single file for ambiguous cases
|
||||
return false
|
||||
}
|
||||
176
pkg/writers/gorm/README.md
Normal file
176
pkg/writers/gorm/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# GORM Writer
|
||||
|
||||
Generates Go source files with GORM model definitions from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The GORM Writer converts RelSpec's internal database model representation into Go source code with GORM struct definitions, complete with proper tags, relationships, and methods.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates GORM-compatible Go structs
|
||||
- Creates proper `gorm` struct tags
|
||||
- Generates `TableName()` methods
|
||||
- Adds relationship fields (belongs-to, has-many)
|
||||
- Supports both single-file and multi-file output
|
||||
- Auto-generates helper methods (optional)
|
||||
- Maps SQL types to Go types
|
||||
- Handles nullable fields with custom sql_types
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Assume db is a *models.Database from a reader
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
}
|
||||
|
||||
writer := gorm.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate GORM models from PostgreSQL database (single file)
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output gorm \
|
||||
--out-file models.go \
|
||||
--package models
|
||||
|
||||
# Generate GORM models with multi-file output (one file per table)
|
||||
relspec --input json \
|
||||
--in-file schema.json \
|
||||
--output gorm \
|
||||
--out-file models/ \
|
||||
--package models
|
||||
|
||||
# Convert DBML to GORM models
|
||||
relspec --input dbml --in-file schema.dbml --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
## Output Modes
|
||||
|
||||
### Single File Mode
|
||||
|
||||
Generates all models in one file:
|
||||
|
||||
```bash
|
||||
relspec --input pgsql --conn "..." --output gorm --out-file models.go
|
||||
```
|
||||
|
||||
### Multi-File Mode
|
||||
|
||||
Generates one file per table (auto-detected when output is a directory):
|
||||
|
||||
```bash
|
||||
relspec --input pgsql --conn "..." --output gorm --out-file models/
|
||||
```
|
||||
|
||||
Files are named: `sql_{schema}_{table}.go`
|
||||
|
||||
## Generated Code Example
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
sql_types "git.warky.dev/wdevs/sql_types"
|
||||
)
|
||||
|
||||
type ModelUser struct {
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"column:username;type:varchar(50);not null;uniqueIndex" json:"username"`
|
||||
Email string `gorm:"column:email;type:varchar(100);not null" json:"email"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:now()" json:"created_at"`
|
||||
|
||||
// Relationships
|
||||
Pos []*ModelPost `gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE" json:"pos,omitempty"`
|
||||
}
|
||||
|
||||
func (ModelUser) TableName() string {
|
||||
return "public.users"
|
||||
}
|
||||
|
||||
type ModelPost struct {
|
||||
ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"`
|
||||
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
|
||||
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
|
||||
Content sql_types.SqlString `gorm:"column:content;type:text" json:"content,omitempty"`
|
||||
|
||||
// Belongs to
|
||||
Use *ModelUser `gorm:"foreignKey:UserID;references:ID" json:"use,omitempty"`
|
||||
}
|
||||
|
||||
func (ModelPost) TableName() string {
|
||||
return "public.posts"
|
||||
}
|
||||
```
|
||||
|
||||
## Writer Options
|
||||
|
||||
### Metadata Options
|
||||
|
||||
Configure the writer behavior using metadata in `WriterOptions`:
|
||||
|
||||
```go
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "models.go",
|
||||
PackageName: "models",
|
||||
Metadata: map[string]interface{}{
|
||||
"multi_file": true, // Enable multi-file mode
|
||||
"populate_refs": true, // Populate RefDatabase/RefSchema
|
||||
"generate_get_id_str": true, // Generate GetIDStr() methods
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | Go Type | Notes |
|
||||
|----------|---------|-------|
|
||||
| bigint, int8 | int64 | - |
|
||||
| integer, int, int4 | int | - |
|
||||
| smallint, int2 | int16 | - |
|
||||
| varchar, text | string | Not nullable |
|
||||
| varchar, text (nullable) | sql_types.SqlString | Nullable |
|
||||
| boolean, bool | bool | - |
|
||||
| timestamp, timestamptz | time.Time | - |
|
||||
| numeric, decimal | float64 | - |
|
||||
| uuid | string | - |
|
||||
| json, jsonb | string | - |
|
||||
|
||||
## Relationship Generation
|
||||
|
||||
The writer automatically generates relationship fields:
|
||||
|
||||
- **Belongs-to**: Generated for tables with foreign keys
|
||||
- **Has-many**: Generated for tables referenced by foreign keys
|
||||
- Relationship field names use 3-letter prefixes
|
||||
- Includes proper `gorm` tags with `foreignKey` and `references`
|
||||
|
||||
## Notes
|
||||
|
||||
- Model names are prefixed with "Model" (e.g., `ModelUser`)
|
||||
- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc.
|
||||
- Generated code is auto-formatted with `go fmt`
|
||||
- JSON tags are automatically added
|
||||
- Supports schema-qualified table names in `TableName()` method
|
||||
@@ -41,12 +41,7 @@ func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
// WriteDatabase writes a complete database as GORM models
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Check if multi-file mode is enabled
|
||||
multiFile := false
|
||||
if w.options.Metadata != nil {
|
||||
if mf, ok := w.options.Metadata["multi_file"].(bool); ok {
|
||||
multiFile = mf
|
||||
}
|
||||
}
|
||||
multiFile := w.shouldUseMultiFile()
|
||||
|
||||
if multiFile {
|
||||
return w.writeMultiFile(db)
|
||||
@@ -340,6 +335,41 @@ func (w *Writer) writeOutput(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
if w.options.Metadata != nil {
|
||||
if mf, ok := w.options.Metadata["multi_file"].(bool); ok {
|
||||
return mf
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect based on output path
|
||||
if w.options.OutputPath == "" {
|
||||
// No output path means stdout (single file)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with .go (explicit file)
|
||||
if strings.HasSuffix(w.options.OutputPath, ".go") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path ends with directory separator
|
||||
if strings.HasSuffix(w.options.OutputPath, "/") || strings.HasSuffix(w.options.OutputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
info, err := os.Stat(w.options.OutputPath)
|
||||
if err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to single file for ambiguous cases
|
||||
return false
|
||||
}
|
||||
|
||||
// createDatabaseRef creates a shallow copy of database without schemas to avoid circular references
|
||||
func (w *Writer) createDatabaseRef(db *models.Database) *models.Database {
|
||||
return &models.Database{
|
||||
|
||||
272
pkg/writers/graphql/README.md
Normal file
272
pkg/writers/graphql/README.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# GraphQL Schema Writer
|
||||
|
||||
The GraphQL writer converts RelSpec's internal database model into GraphQL Schema Definition Language (SDL) files.
|
||||
|
||||
## Features
|
||||
|
||||
- **Table to Type mapping**: Database tables become GraphQL types
|
||||
- **Column to Field mapping**: Table columns become type fields
|
||||
- **Enum support**: Database enums are preserved
|
||||
- **Custom scalar declarations**: Automatically declares DateTime, JSON, Date scalars
|
||||
- **Implicit relationships**: Generates relationship fields from foreign keys
|
||||
- **Many-to-many support**: Handles junction tables intelligently
|
||||
- **Clean output**: Proper formatting, field ordering, and comments
|
||||
|
||||
## Type Mappings
|
||||
|
||||
### SQL to GraphQL
|
||||
|
||||
| SQL Type | GraphQL Type | Notes |
|
||||
|----------|--------------|-------|
|
||||
| bigint, integer, serial (PK) | ID | Primary keys map to ID |
|
||||
| bigint, integer, int | Int | |
|
||||
| text, varchar, char | String | |
|
||||
| uuid (PK) | ID | UUID primary keys also map to ID |
|
||||
| uuid | String | Non-PK UUIDs map to String |
|
||||
| double precision, numeric, float | Float | |
|
||||
| boolean | Boolean | |
|
||||
| timestamp, timestamptz | DateTime | Custom scalar |
|
||||
| jsonb, json | JSON | Custom scalar |
|
||||
| date | Date | Custom scalar |
|
||||
| Enum types | Enum | Preserves enum name |
|
||||
| Arrays (e.g., text[]) | [Type] | Mapped to GraphQL lists |
|
||||
|
||||
## Relationship Handling
|
||||
|
||||
The writer intelligently generates relationship fields based on foreign key constraints:
|
||||
|
||||
### Forward Relationships (FK on this table)
|
||||
```sql
|
||||
-- Post table has authorId FK to User.id
|
||||
CREATE TABLE post (
|
||||
id bigint PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
author_id bigint NOT NULL REFERENCES user(id)
|
||||
);
|
||||
```
|
||||
|
||||
```graphql
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
author: User! # Generated from authorId FK
|
||||
}
|
||||
```
|
||||
|
||||
### Reverse Relationships (FK on other table)
|
||||
```graphql
|
||||
type User {
|
||||
id: ID!
|
||||
email: String!
|
||||
posts: [Post!]! # Reverse relationship (Post has FK to User)
|
||||
}
|
||||
```
|
||||
|
||||
### Many-to-Many Relationships
|
||||
|
||||
Junction tables (tables with only PKs and FKs) are automatically detected and hidden:
|
||||
|
||||
```sql
|
||||
CREATE TABLE post_tag (
|
||||
post_id bigint NOT NULL REFERENCES post(id),
|
||||
tag_id bigint NOT NULL REFERENCES tag(id),
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
```
|
||||
|
||||
```graphql
|
||||
type Post {
|
||||
id: ID!
|
||||
tags: [Tag!]! # Many-to-many through PostTag junction table
|
||||
}
|
||||
|
||||
type Tag {
|
||||
id: ID!
|
||||
posts: [Post!]! # Reverse many-to-many
|
||||
}
|
||||
|
||||
# Note: PostTag junction table is NOT included in output
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
)
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "schema.graphql",
|
||||
}
|
||||
|
||||
writer := graphql.NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
### With Metadata Options
|
||||
|
||||
```go
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "schema.graphql",
|
||||
Metadata: map[string]any{
|
||||
"includeScalarDeclarations": true, // Include scalar declarations
|
||||
"includeComments": true, // Include field/table comments
|
||||
},
|
||||
}
|
||||
|
||||
writer := graphql.NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
### Write to Stdout
|
||||
|
||||
```go
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "", // Empty path writes to stdout
|
||||
}
|
||||
|
||||
writer := graphql.NewWriter(opts)
|
||||
err := writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# Convert PostgreSQL database to GraphQL
|
||||
relspec convert --from pgsql \
|
||||
--from-conn "postgres://user:pass@localhost:5432/mydb" \
|
||||
--to graphql --to-path schema.graphql
|
||||
|
||||
# Convert GORM models to GraphQL
|
||||
relspec convert --from gorm --from-path ./models \
|
||||
--to graphql --to-path schema.graphql
|
||||
|
||||
# Convert JSON to GraphQL
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to graphql --to-path schema.graphql
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
The generated GraphQL schema follows this structure:
|
||||
|
||||
1. **Header comment** (if enabled)
|
||||
2. **Custom scalar declarations** (if any custom scalars are used)
|
||||
3. **Enum definitions** (alphabetically sorted)
|
||||
4. **Type definitions** (with fields ordered: ID first, then scalars alphabetically, then relationships)
|
||||
|
||||
### Example Output
|
||||
|
||||
```graphql
|
||||
# Generated GraphQL Schema
|
||||
# Database: myapp
|
||||
|
||||
scalar DateTime
|
||||
scalar JSON
|
||||
scalar Date
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: DateTime!
|
||||
email: String!
|
||||
name: String!
|
||||
role: Role!
|
||||
|
||||
posts: [Post!]!
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
content: String
|
||||
published: Boolean!
|
||||
publishedAt: Date
|
||||
title: String!
|
||||
|
||||
author: User!
|
||||
tags: [Tag!]!
|
||||
}
|
||||
|
||||
type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
|
||||
posts: [Post!]!
|
||||
}
|
||||
```
|
||||
|
||||
## Metadata Options
|
||||
|
||||
| Option | Type | Description | Default |
|
||||
|--------|------|-------------|---------|
|
||||
| `includeScalarDeclarations` | bool | Include `scalar DateTime`, etc. declarations | true |
|
||||
| `includeComments` | bool | Include table/field descriptions as comments | true |
|
||||
| `preservePKType` | bool | Use Int/String for PKs instead of ID | false |
|
||||
|
||||
## Field Naming Conventions
|
||||
|
||||
- **FK columns**: Foreign key columns like `authorId` are removed from the output; instead, a relationship field `author` is generated
|
||||
- **Relationship pluralization**: Reverse one-to-many relationships are pluralized (e.g., `posts`, `tags`)
|
||||
- **CamelCase**: Field names are kept in their original casing from the database
|
||||
|
||||
## Junction Table Detection
|
||||
|
||||
A table is considered a junction table if it:
|
||||
1. Has exactly 2 foreign key constraints
|
||||
2. All columns are either primary keys or foreign keys
|
||||
3. Has a composite primary key on the FK columns
|
||||
|
||||
Junction tables are automatically hidden from the GraphQL output, and many-to-many relationship fields are generated on the related types instead.
|
||||
|
||||
## Limitations
|
||||
|
||||
- All tables in all schemas are flattened into a single GraphQL schema
|
||||
- No support for GraphQL-specific features like directives, interfaces, or unions
|
||||
- Nullable vs non-nullable is determined solely by the `NOT NULL` constraint
|
||||
|
||||
## Example Conversion
|
||||
|
||||
**Input** (Database Schema):
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
id bigint PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
created_at timestamp NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post (
|
||||
id bigint PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
author_id bigint NOT NULL REFERENCES user(id)
|
||||
);
|
||||
```
|
||||
|
||||
**Output** (GraphQL Schema):
|
||||
```graphql
|
||||
scalar DateTime
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: DateTime!
|
||||
email: String!
|
||||
|
||||
posts: [Post!]!
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
|
||||
author: User!
|
||||
}
|
||||
```
|
||||
178
pkg/writers/graphql/relationships.go
Normal file
178
pkg/writers/graphql/relationships.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func (w *Writer) generateRelationFields(table *models.Table, db *models.Database, schema *models.Schema) []string {
|
||||
var fields []string
|
||||
|
||||
// 1. Forward relationships (this table has FK)
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the related table
|
||||
relatedTable := w.findTable(db, constraint.ReferencedSchema, constraint.ReferencedTable)
|
||||
if relatedTable == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate field name (remove "Id" suffix from FK column if present)
|
||||
fieldName := w.relationFieldName(constraint.Columns[0])
|
||||
|
||||
// Determine nullability from FK column
|
||||
nullable := true
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
if col.NotNull {
|
||||
nullable = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format: fieldName: RelatedType! or fieldName: RelatedType
|
||||
gqlType := relatedTable.Name
|
||||
if !nullable {
|
||||
gqlType += "!"
|
||||
}
|
||||
|
||||
fields = append(fields, fmt.Sprintf(" %s: %s", fieldName, gqlType))
|
||||
}
|
||||
|
||||
// 2. Reverse relationships (other tables reference this table)
|
||||
for _, otherSchema := range db.Schemas {
|
||||
for _, otherTable := range otherSchema.Tables {
|
||||
if otherTable.Name == table.Name && otherSchema.Name == schema.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip join tables for many-to-many
|
||||
if w.isJoinTable(otherTable) {
|
||||
// Check if this is a many-to-many through this join table
|
||||
if m2mField := w.getManyToManyField(table, otherTable, db); m2mField != "" {
|
||||
fields = append(fields, m2mField)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, constraint := range otherTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint &&
|
||||
constraint.ReferencedTable == table.Name &&
|
||||
constraint.ReferencedSchema == schema.Name {
|
||||
// Add reverse relationship field (array)
|
||||
fieldName := w.pluralize(w.camelCase(otherTable.Name))
|
||||
fields = append(fields, fmt.Sprintf(" %s: [%s!]!", fieldName, otherTable.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func (w *Writer) getManyToManyField(table *models.Table, joinTable *models.Table, db *models.Database) string {
|
||||
// Find the two FK constraints in the join table
|
||||
var fk1, fk2 *models.Constraint
|
||||
for _, constraint := range joinTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
if fk1 == nil {
|
||||
fk1 = constraint
|
||||
} else {
|
||||
fk2 = constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fk1 == nil || fk2 == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine which FK points to our table and which to the other table
|
||||
var targetConstraint *models.Constraint
|
||||
if fk1.ReferencedTable == table.Name {
|
||||
targetConstraint = fk2
|
||||
} else if fk2.ReferencedTable == table.Name {
|
||||
targetConstraint = fk1
|
||||
} else {
|
||||
return "" // This join table doesn't involve our table
|
||||
}
|
||||
|
||||
// Find the target table
|
||||
targetTable := w.findTable(db, targetConstraint.ReferencedSchema, targetConstraint.ReferencedTable)
|
||||
if targetTable == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Generate many-to-many field
|
||||
fieldName := w.pluralize(w.camelCase(targetTable.Name))
|
||||
return fmt.Sprintf(" %s: [%s!]!", fieldName, targetTable.Name)
|
||||
}
|
||||
|
||||
func (w *Writer) findTable(db *models.Database, schemaName, tableName string) *models.Table {
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name != schemaName {
|
||||
continue
|
||||
}
|
||||
for _, table := range schema.Tables {
|
||||
if table.Name == tableName {
|
||||
return table
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) relationFieldName(fkColumnName string) string {
|
||||
// Remove "Id" or "_id" suffix
|
||||
name := fkColumnName
|
||||
if strings.HasSuffix(name, "Id") {
|
||||
name = name[:len(name)-2]
|
||||
} else if strings.HasSuffix(name, "_id") {
|
||||
name = name[:len(name)-3]
|
||||
}
|
||||
|
||||
return w.camelCase(name)
|
||||
}
|
||||
|
||||
func (w *Writer) camelCase(s string) string {
|
||||
// If already camelCase or PascalCase, convert to camelCase
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Convert first character to lowercase
|
||||
return strings.ToLower(string(s[0])) + s[1:]
|
||||
}
|
||||
|
||||
func (w *Writer) pluralize(s string) string {
|
||||
// Simple pluralization rules
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Already plural
|
||||
if strings.HasSuffix(s, "s") {
|
||||
return s
|
||||
}
|
||||
|
||||
// Words ending in 'y' → 'ies'
|
||||
if strings.HasSuffix(s, "y") {
|
||||
return s[:len(s)-1] + "ies"
|
||||
}
|
||||
|
||||
// Words ending in 's', 'x', 'z', 'ch', 'sh' → add 'es'
|
||||
if strings.HasSuffix(s, "s") || strings.HasSuffix(s, "x") ||
|
||||
strings.HasSuffix(s, "z") || strings.HasSuffix(s, "ch") ||
|
||||
strings.HasSuffix(s, "sh") {
|
||||
return s + "es"
|
||||
}
|
||||
|
||||
// Default: add 's'
|
||||
return s + "s"
|
||||
}
|
||||
148
pkg/writers/graphql/type_mapping.go
Normal file
148
pkg/writers/graphql/type_mapping.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func (w *Writer) sqlTypeToGraphQL(sqlType string, column *models.Column, table *models.Table, schema *models.Schema) string {
|
||||
// Check if this is a primary key → ID type
|
||||
if column.IsPrimaryKey {
|
||||
// Check metadata for explicit type preference
|
||||
if w.options.Metadata != nil {
|
||||
if preserveType, ok := w.options.Metadata["preservePKType"].(bool); ok && preserveType {
|
||||
// Use Int or String based on SQL type
|
||||
if w.isIntegerType(sqlType) {
|
||||
return "Int"
|
||||
}
|
||||
return "String"
|
||||
}
|
||||
}
|
||||
return "ID"
|
||||
}
|
||||
|
||||
// Map SQL types to custom scalars
|
||||
if scalar := w.sqlTypeToCustomScalar(sqlType); scalar != "" {
|
||||
return scalar
|
||||
}
|
||||
|
||||
// Check if it's an enum
|
||||
if w.isEnumType(sqlType, schema) {
|
||||
return sqlType
|
||||
}
|
||||
|
||||
// Standard type mappings
|
||||
baseType := strings.Split(sqlType, "(")[0] // Remove length/precision
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
// Handle array types
|
||||
if strings.HasSuffix(baseType, "[]") {
|
||||
elemType := strings.TrimSuffix(baseType, "[]")
|
||||
gqlType := w.mapBaseTypeToGraphQL(elemType)
|
||||
return "[" + gqlType + "]"
|
||||
}
|
||||
|
||||
return w.mapBaseTypeToGraphQL(baseType)
|
||||
}
|
||||
|
||||
func (w *Writer) mapBaseTypeToGraphQL(baseType string) string {
|
||||
typeMap := map[string]string{
|
||||
// Text types
|
||||
"text": "String",
|
||||
"varchar": "String",
|
||||
"char": "String",
|
||||
"character": "String",
|
||||
"bpchar": "String",
|
||||
"name": "String",
|
||||
|
||||
// UUID
|
||||
"uuid": "ID",
|
||||
|
||||
// Integer types
|
||||
"integer": "Int",
|
||||
"int": "Int",
|
||||
"int2": "Int",
|
||||
"int4": "Int",
|
||||
"int8": "Int",
|
||||
"bigint": "Int",
|
||||
"smallint": "Int",
|
||||
"serial": "Int",
|
||||
"bigserial": "Int",
|
||||
"smallserial": "Int",
|
||||
|
||||
// Float types
|
||||
"double precision": "Float",
|
||||
"float": "Float",
|
||||
"float4": "Float",
|
||||
"float8": "Float",
|
||||
"real": "Float",
|
||||
"numeric": "Float",
|
||||
"decimal": "Float",
|
||||
"money": "Float",
|
||||
|
||||
// Boolean
|
||||
"boolean": "Boolean",
|
||||
"bool": "Boolean",
|
||||
}
|
||||
|
||||
if gqlType, ok := typeMap[baseType]; ok {
|
||||
return gqlType
|
||||
}
|
||||
|
||||
// Default: capitalize first letter
|
||||
if len(baseType) > 0 {
|
||||
return strings.ToUpper(string(baseType[0])) + baseType[1:]
|
||||
}
|
||||
|
||||
return "String"
|
||||
}
|
||||
|
||||
func (w *Writer) sqlTypeToCustomScalar(sqlType string) string {
|
||||
scalarMap := map[string]string{
|
||||
"timestamp": "DateTime",
|
||||
"timestamptz": "DateTime",
|
||||
"timestamp with time zone": "DateTime",
|
||||
"jsonb": "JSON",
|
||||
"json": "JSON",
|
||||
"date": "Date",
|
||||
}
|
||||
|
||||
baseType := strings.Split(sqlType, "(")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
if scalar, ok := scalarMap[baseType]; ok {
|
||||
return scalar
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *Writer) isIntegerType(sqlType string) bool {
|
||||
intTypes := map[string]bool{
|
||||
"integer": true,
|
||||
"int": true,
|
||||
"int2": true,
|
||||
"int4": true,
|
||||
"int8": true,
|
||||
"bigint": true,
|
||||
"smallint": true,
|
||||
"serial": true,
|
||||
"bigserial": true,
|
||||
"smallserial": true,
|
||||
}
|
||||
|
||||
baseType := strings.Split(sqlType, "(")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
return intTypes[baseType]
|
||||
}
|
||||
|
||||
func (w *Writer) isEnumType(sqlType string, schema *models.Schema) bool {
|
||||
for _, enum := range schema.Enums {
|
||||
if enum.Name == sqlType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
272
pkg/writers/graphql/writer.go
Normal file
272
pkg/writers/graphql/writer.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
}
|
||||
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
return &Writer{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
content := w.databaseToGraphQL(db)
|
||||
|
||||
if w.options.OutputPath != "" {
|
||||
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
fmt.Print(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteTable(table *models.Table) error {
|
||||
schema := models.InitSchema(table.Schema)
|
||||
schema.Tables = []*models.Table{table}
|
||||
db := models.InitDatabase(schema.Name)
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
func (w *Writer) databaseToGraphQL(db *models.Database) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Header comment
|
||||
if w.shouldIncludeComments() {
|
||||
sb.WriteString("# Generated GraphQL Schema\n")
|
||||
if db.Name != "" {
|
||||
sb.WriteString(fmt.Sprintf("# Database: %s\n", db.Name))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Custom scalar declarations
|
||||
if w.shouldIncludeScalarDeclarations() {
|
||||
scalars := w.collectCustomScalars(db)
|
||||
if len(scalars) > 0 {
|
||||
for _, scalar := range scalars {
|
||||
sb.WriteString(fmt.Sprintf("scalar %s\n", scalar))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Enum definitions
|
||||
for _, schema := range db.Schemas {
|
||||
for _, enum := range schema.Enums {
|
||||
sb.WriteString(w.enumToGraphQL(enum))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
// Skip join tables (tables with only PK+FK columns)
|
||||
if w.isJoinTable(table) {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(w.tableToGraphQL(table, db, schema))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) shouldIncludeComments() bool {
|
||||
if w.options.Metadata != nil {
|
||||
if include, ok := w.options.Metadata["includeComments"].(bool); ok {
|
||||
return include
|
||||
}
|
||||
}
|
||||
return true // Default to true
|
||||
}
|
||||
|
||||
func (w *Writer) shouldIncludeScalarDeclarations() bool {
|
||||
if w.options.Metadata != nil {
|
||||
if include, ok := w.options.Metadata["includeScalarDeclarations"].(bool); ok {
|
||||
return include
|
||||
}
|
||||
}
|
||||
return true // Default to true
|
||||
}
|
||||
|
||||
func (w *Writer) collectCustomScalars(db *models.Database) []string {
|
||||
scalarsNeeded := make(map[string]bool)
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
for _, col := range table.Columns {
|
||||
if scalar := w.sqlTypeToCustomScalar(col.Type); scalar != "" {
|
||||
scalarsNeeded[scalar] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted slice
|
||||
scalars := make([]string, 0, len(scalarsNeeded))
|
||||
for scalar := range scalarsNeeded {
|
||||
scalars = append(scalars, scalar)
|
||||
}
|
||||
sort.Strings(scalars)
|
||||
|
||||
return scalars
|
||||
}
|
||||
|
||||
func (w *Writer) isJoinTable(table *models.Table) bool {
|
||||
// A join table typically has:
|
||||
// 1. Exactly 2 FK constraints
|
||||
// 2. Composite primary key on those FK columns
|
||||
// 3. No other columns
|
||||
|
||||
fkCount := 0
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
fkCount++
|
||||
}
|
||||
}
|
||||
|
||||
if fkCount != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all columns are either PKs or FKs
|
||||
for _, col := range table.Columns {
|
||||
isFKColumn := false
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, fkCol := range constraint.Columns {
|
||||
if fkCol == col.Name {
|
||||
isFKColumn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isFKColumn && !col.IsPrimaryKey {
|
||||
// Found a column that's neither PK nor FK
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Writer) enumToGraphQL(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name))
|
||||
for _, value := range enum.Values {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", value))
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema *models.Schema) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Type name
|
||||
typeName := table.Name
|
||||
|
||||
// Description comment
|
||||
if w.shouldIncludeComments() && (table.Description != "" || table.Comment != "") {
|
||||
desc := table.Description
|
||||
if desc == "" {
|
||||
desc = table.Comment
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("# %s\n", desc))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("type %s {\n", typeName))
|
||||
|
||||
// Collect and categorize fields
|
||||
var idFields, scalarFields, relationFields []string
|
||||
|
||||
for _, column := range table.Columns {
|
||||
// Skip FK columns (they become relation fields)
|
||||
if w.isForeignKeyColumn(column, table) {
|
||||
continue
|
||||
}
|
||||
|
||||
gqlType := w.sqlTypeToGraphQL(column.Type, column, table, schema)
|
||||
if gqlType == "" {
|
||||
continue // Skip if type couldn't be mapped
|
||||
}
|
||||
|
||||
// Determine nullability
|
||||
if column.NotNull {
|
||||
gqlType += "!"
|
||||
}
|
||||
|
||||
field := fmt.Sprintf(" %s: %s", column.Name, gqlType)
|
||||
|
||||
if column.IsPrimaryKey {
|
||||
idFields = append(idFields, field)
|
||||
} else {
|
||||
scalarFields = append(scalarFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
// Add relation fields
|
||||
relationFields = w.generateRelationFields(table, db, schema)
|
||||
|
||||
// Write fields in order: ID, scalars (sorted), relations (sorted)
|
||||
for _, field := range idFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
|
||||
sort.Strings(scalarFields)
|
||||
for _, field := range scalarFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
|
||||
if len(relationFields) > 0 {
|
||||
if len(scalarFields) > 0 || len(idFields) > 0 {
|
||||
sb.WriteString("\n") // Blank line before relations
|
||||
}
|
||||
sort.Strings(relationFields)
|
||||
for _, field := range relationFields {
|
||||
sb.WriteString(field + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (w *Writer) isForeignKeyColumn(column *models.Column, table *models.Table) bool {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, fkCol := range constraint.Columns {
|
||||
if fkCol == column.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
412
pkg/writers/graphql/writer_test.go
Normal file
412
pkg/writers/graphql/writer_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestWriter_WriteTable_Simple(t *testing.T) {
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
nameCol := models.InitColumn("name", "User", "public")
|
||||
nameCol.Type = "text"
|
||||
nameCol.NotNull = true
|
||||
table.Columns["name"] = nameCol
|
||||
|
||||
emailCol := models.InitColumn("email", "User", "public")
|
||||
emailCol.Type = "text"
|
||||
emailCol.NotNull = false
|
||||
table.Columns["email"] = emailCol
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
OutputPath: "",
|
||||
}
|
||||
|
||||
writer := NewWriter(opts)
|
||||
schema := models.InitSchema("public")
|
||||
schema.Tables = []*models.Table{table}
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify output contains type definition
|
||||
if !strings.Contains(output, "type User {") {
|
||||
t.Error("Expected 'type User {' in output")
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if !strings.Contains(output, "id: ID!") {
|
||||
t.Error("Expected 'id: ID!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "name: String!") {
|
||||
t.Error("Expected 'name: String!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "email: String") {
|
||||
t.Error("Expected 'email: String' in output")
|
||||
}
|
||||
|
||||
// Ensure email is not followed by ! (nullable)
|
||||
if strings.Contains(output, "email: String!") {
|
||||
t.Error("Did not expect 'email: String!' (should be nullable)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_WithEnum(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create enum
|
||||
roleEnum := &models.Enum{
|
||||
Name: "Role",
|
||||
Schema: "public",
|
||||
Values: []string{"ADMIN", "USER", "GUEST"},
|
||||
}
|
||||
schema.Enums = []*models.Enum{roleEnum}
|
||||
|
||||
// Create table with enum field
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
roleCol := models.InitColumn("role", "User", "public")
|
||||
roleCol.Type = "Role"
|
||||
roleCol.NotNull = true
|
||||
table.Columns["role"] = roleCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify enum definition
|
||||
if !strings.Contains(output, "enum Role {") {
|
||||
t.Error("Expected 'enum Role {' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "ADMIN") {
|
||||
t.Error("Expected 'ADMIN' enum value in output")
|
||||
}
|
||||
|
||||
// Verify enum usage in type
|
||||
if !strings.Contains(output, "role: Role!") {
|
||||
t.Error("Expected 'role: Role!' in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_WithRelations(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create User table
|
||||
userTable := models.InitTable("User", "public")
|
||||
userIdCol := models.InitColumn("id", "User", "public")
|
||||
userIdCol.Type = "bigint"
|
||||
userIdCol.IsPrimaryKey = true
|
||||
userIdCol.NotNull = true
|
||||
userTable.Columns["id"] = userIdCol
|
||||
|
||||
userNameCol := models.InitColumn("name", "User", "public")
|
||||
userNameCol.Type = "text"
|
||||
userNameCol.NotNull = true
|
||||
userTable.Columns["name"] = userNameCol
|
||||
|
||||
// Create Post table with FK to User
|
||||
postTable := models.InitTable("Post", "public")
|
||||
|
||||
postIdCol := models.InitColumn("id", "Post", "public")
|
||||
postIdCol.Type = "bigint"
|
||||
postIdCol.IsPrimaryKey = true
|
||||
postIdCol.NotNull = true
|
||||
postTable.Columns["id"] = postIdCol
|
||||
|
||||
titleCol := models.InitColumn("title", "Post", "public")
|
||||
titleCol.Type = "text"
|
||||
titleCol.NotNull = true
|
||||
postTable.Columns["title"] = titleCol
|
||||
|
||||
authorIdCol := models.InitColumn("authorId", "Post", "public")
|
||||
authorIdCol.Type = "bigint"
|
||||
authorIdCol.NotNull = true
|
||||
postTable.Columns["authorId"] = authorIdCol
|
||||
|
||||
// Add FK constraint
|
||||
fkConstraint := models.InitConstraint("fk_post_author", models.ForeignKeyConstraint)
|
||||
fkConstraint.Schema = "public"
|
||||
fkConstraint.Table = "Post"
|
||||
fkConstraint.Columns = []string{"authorId"}
|
||||
fkConstraint.ReferencedSchema = "public"
|
||||
fkConstraint.ReferencedTable = "User"
|
||||
fkConstraint.ReferencedColumns = []string{"id"}
|
||||
postTable.Constraints["fk_post_author"] = fkConstraint
|
||||
|
||||
schema.Tables = []*models.Table{userTable, postTable}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify Post has author field (forward relationship)
|
||||
if !strings.Contains(output, "author: User!") {
|
||||
t.Error("Expected 'author: User!' in Post type")
|
||||
}
|
||||
|
||||
// Verify authorId FK column is NOT in the output
|
||||
if strings.Contains(output, "authorId:") {
|
||||
t.Error("Did not expect 'authorId:' field in output (FK columns should be hidden)")
|
||||
}
|
||||
|
||||
// Verify User has posts field (reverse relationship)
|
||||
if !strings.Contains(output, "posts: [Post!]!") {
|
||||
t.Error("Expected 'posts: [Post!]!' in User type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_CustomScalars(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("Event", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "Event", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
createdAtCol := models.InitColumn("createdAt", "Event", "public")
|
||||
createdAtCol.Type = "timestamp"
|
||||
createdAtCol.NotNull = true
|
||||
table.Columns["createdAt"] = createdAtCol
|
||||
|
||||
metadataCol := models.InitColumn("metadata", "Event", "public")
|
||||
metadataCol.Type = "jsonb"
|
||||
metadataCol.NotNull = false
|
||||
table.Columns["metadata"] = metadataCol
|
||||
|
||||
dateCol := models.InitColumn("eventDate", "Event", "public")
|
||||
dateCol.Type = "date"
|
||||
dateCol.NotNull = false
|
||||
table.Columns["eventDate"] = dateCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify scalar declarations
|
||||
if !strings.Contains(output, "scalar DateTime") {
|
||||
t.Error("Expected 'scalar DateTime' declaration")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "scalar JSON") {
|
||||
t.Error("Expected 'scalar JSON' declaration")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "scalar Date") {
|
||||
t.Error("Expected 'scalar Date' declaration")
|
||||
}
|
||||
|
||||
// Verify field types
|
||||
if !strings.Contains(output, "createdAt: DateTime!") {
|
||||
t.Error("Expected 'createdAt: DateTime!' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "metadata: JSON") {
|
||||
t.Error("Expected 'metadata: JSON' in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "eventDate: Date") {
|
||||
t.Error("Expected 'eventDate: Date' in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_ManyToMany(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
// Create Post table
|
||||
postTable := models.InitTable("Post", "public")
|
||||
postIdCol := models.InitColumn("id", "Post", "public")
|
||||
postIdCol.Type = "bigint"
|
||||
postIdCol.IsPrimaryKey = true
|
||||
postIdCol.NotNull = true
|
||||
postTable.Columns["id"] = postIdCol
|
||||
|
||||
titleCol := models.InitColumn("title", "Post", "public")
|
||||
titleCol.Type = "text"
|
||||
titleCol.NotNull = true
|
||||
postTable.Columns["title"] = titleCol
|
||||
|
||||
// Create Tag table
|
||||
tagTable := models.InitTable("Tag", "public")
|
||||
tagIdCol := models.InitColumn("id", "Tag", "public")
|
||||
tagIdCol.Type = "bigint"
|
||||
tagIdCol.IsPrimaryKey = true
|
||||
tagIdCol.NotNull = true
|
||||
tagTable.Columns["id"] = tagIdCol
|
||||
|
||||
nameCol := models.InitColumn("name", "Tag", "public")
|
||||
nameCol.Type = "text"
|
||||
nameCol.NotNull = true
|
||||
tagTable.Columns["name"] = nameCol
|
||||
|
||||
// Create PostTag join table
|
||||
joinTable := models.InitTable("PostTag", "public")
|
||||
|
||||
postIdJoinCol := models.InitColumn("postId", "PostTag", "public")
|
||||
postIdJoinCol.Type = "bigint"
|
||||
postIdJoinCol.NotNull = true
|
||||
postIdJoinCol.IsPrimaryKey = true
|
||||
joinTable.Columns["postId"] = postIdJoinCol
|
||||
|
||||
tagIdJoinCol := models.InitColumn("tagId", "PostTag", "public")
|
||||
tagIdJoinCol.Type = "bigint"
|
||||
tagIdJoinCol.NotNull = true
|
||||
tagIdJoinCol.IsPrimaryKey = true
|
||||
joinTable.Columns["tagId"] = tagIdJoinCol
|
||||
|
||||
// Add composite PK constraint
|
||||
pkConstraint := models.InitConstraint("pk_posttag", models.PrimaryKeyConstraint)
|
||||
pkConstraint.Schema = "public"
|
||||
pkConstraint.Table = "PostTag"
|
||||
pkConstraint.Columns = []string{"postId", "tagId"}
|
||||
joinTable.Constraints["pk_posttag"] = pkConstraint
|
||||
|
||||
// Add FK to Post
|
||||
fk1 := models.InitConstraint("fk_posttag_post", models.ForeignKeyConstraint)
|
||||
fk1.Schema = "public"
|
||||
fk1.Table = "PostTag"
|
||||
fk1.Columns = []string{"postId"}
|
||||
fk1.ReferencedSchema = "public"
|
||||
fk1.ReferencedTable = "Post"
|
||||
fk1.ReferencedColumns = []string{"id"}
|
||||
joinTable.Constraints["fk_posttag_post"] = fk1
|
||||
|
||||
// Add FK to Tag
|
||||
fk2 := models.InitConstraint("fk_posttag_tag", models.ForeignKeyConstraint)
|
||||
fk2.Schema = "public"
|
||||
fk2.Table = "PostTag"
|
||||
fk2.Columns = []string{"tagId"}
|
||||
fk2.ReferencedSchema = "public"
|
||||
fk2.ReferencedTable = "Tag"
|
||||
fk2.ReferencedColumns = []string{"id"}
|
||||
joinTable.Constraints["fk_posttag_tag"] = fk2
|
||||
|
||||
schema.Tables = []*models.Table{postTable, tagTable, joinTable}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify join table is NOT in output
|
||||
if strings.Contains(output, "type PostTag") {
|
||||
t.Error("Did not expect 'type PostTag' (join tables should be hidden)")
|
||||
}
|
||||
|
||||
// Verify Post has tags field
|
||||
if !strings.Contains(output, "tags: [Tag!]!") {
|
||||
t.Error("Expected 'tags: [Tag!]!' in Post type")
|
||||
}
|
||||
|
||||
// Verify Tag has posts field
|
||||
if !strings.Contains(output, "posts: [Post!]!") {
|
||||
t.Error("Expected 'posts: [Post!]!' in Tag type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_UUIDType(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("User", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "User", "public")
|
||||
idCol.Type = "uuid"
|
||||
idCol.IsPrimaryKey = true
|
||||
idCol.NotNull = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// UUID primary keys should still map to ID
|
||||
if !strings.Contains(output, "id: ID!") {
|
||||
t.Error("Expected 'id: ID!' for UUID primary key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriter_Metadata_NoScalarDeclarations(t *testing.T) {
|
||||
schema := models.InitSchema("public")
|
||||
|
||||
table := models.InitTable("Event", "public")
|
||||
|
||||
idCol := models.InitColumn("id", "Event", "public")
|
||||
idCol.Type = "bigint"
|
||||
idCol.IsPrimaryKey = true
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
createdAtCol := models.InitColumn("createdAt", "Event", "public")
|
||||
createdAtCol.Type = "timestamp"
|
||||
createdAtCol.NotNull = true
|
||||
table.Columns["createdAt"] = createdAtCol
|
||||
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
db := models.InitDatabase("test")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
opts := &writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"includeScalarDeclarations": false,
|
||||
},
|
||||
}
|
||||
writer := NewWriter(opts)
|
||||
|
||||
output := writer.databaseToGraphQL(db)
|
||||
|
||||
// Verify no scalar declarations
|
||||
if strings.Contains(output, "scalar DateTime") {
|
||||
t.Error("Did not expect 'scalar DateTime' with includeScalarDeclarations=false")
|
||||
}
|
||||
|
||||
// But field should still use DateTime
|
||||
if !strings.Contains(output, "createdAt: DateTime!") {
|
||||
t.Error("Expected 'createdAt: DateTime!' in output")
|
||||
}
|
||||
}
|
||||
277
pkg/writers/json/README.md
Normal file
277
pkg/writers/json/README.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# JSON Writer
|
||||
|
||||
Generates database schema definitions in JSON format.
|
||||
|
||||
## Overview
|
||||
|
||||
The JSON Writer converts RelSpec's internal database model representation into JSON format, providing a complete, structured representation of the database schema.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates RelSpec's canonical JSON schema format
|
||||
- Complete schema representation including:
|
||||
- Databases and schemas
|
||||
- Tables, columns, and data types
|
||||
- Constraints (PK, FK, unique, check)
|
||||
- Indexes
|
||||
- Relationships
|
||||
- Views and sequences
|
||||
- Pretty-printed, human-readable output
|
||||
- Suitable for version control
|
||||
- Ideal interchange format
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.json",
|
||||
}
|
||||
|
||||
writer := json.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Export PostgreSQL database to JSON
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output json \
|
||||
--out-file schema.json
|
||||
|
||||
# Convert GORM models to JSON
|
||||
relspec --input gorm --in-file models.go --output json --out-file schema.json
|
||||
|
||||
# Convert DBML to JSON
|
||||
relspec --input dbml --in-file diagram.dbml --output json --out-file schema.json
|
||||
```
|
||||
|
||||
## Generated JSON Example
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"description": "",
|
||||
"database_type": "postgresql",
|
||||
"database_version": "",
|
||||
"source_format": "pgsql",
|
||||
"schemas": [
|
||||
{
|
||||
"name": "public",
|
||||
"description": "",
|
||||
"tables": [
|
||||
{
|
||||
"name": "users",
|
||||
"schema": "public",
|
||||
"description": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"type": "bigint",
|
||||
"length": 0,
|
||||
"precision": 0,
|
||||
"scale": 0,
|
||||
"not_null": true,
|
||||
"is_primary_key": true,
|
||||
"auto_increment": true,
|
||||
"default": "",
|
||||
"sequence": 1
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"type": "varchar",
|
||||
"length": 50,
|
||||
"not_null": true,
|
||||
"is_primary_key": false,
|
||||
"auto_increment": false,
|
||||
"sequence": 2
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"type": "varchar",
|
||||
"length": 100,
|
||||
"not_null": true,
|
||||
"sequence": 3
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"pk_users": {
|
||||
"name": "pk_users",
|
||||
"type": "PRIMARY KEY",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"columns": ["id"]
|
||||
},
|
||||
"uq_users_username": {
|
||||
"name": "uq_users_username",
|
||||
"type": "UNIQUE",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"columns": ["username"]
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_users_email": {
|
||||
"name": "idx_users_email",
|
||||
"table": "users",
|
||||
"schema": "public",
|
||||
"columns": ["email"],
|
||||
"unique": false,
|
||||
"type": "btree"
|
||||
}
|
||||
},
|
||||
"relationships": {}
|
||||
},
|
||||
{
|
||||
"name": "posts",
|
||||
"schema": "public",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"not_null": true,
|
||||
"is_primary_key": true,
|
||||
"sequence": 1
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"not_null": true,
|
||||
"sequence": 2
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar",
|
||||
"length": 200,
|
||||
"not_null": true,
|
||||
"sequence": 3
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"not_null": false,
|
||||
"sequence": 4
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"fk_posts_user_id": {
|
||||
"name": "fk_posts_user_id",
|
||||
"type": "FOREIGN KEY",
|
||||
"table": "posts",
|
||||
"schema": "public",
|
||||
"columns": ["user_id"],
|
||||
"referenced_table": "users",
|
||||
"referenced_schema": "public",
|
||||
"referenced_columns": ["id"],
|
||||
"on_delete": "CASCADE",
|
||||
"on_update": "NO ACTION"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_posts_user_id": {
|
||||
"name": "idx_posts_user_id",
|
||||
"columns": ["user_id"],
|
||||
"unique": false,
|
||||
"type": "btree"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"sequences": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Structure
|
||||
|
||||
The JSON format includes:
|
||||
|
||||
### Database Level
|
||||
- `name` - Database name
|
||||
- `description` - Database description
|
||||
- `database_type` - Database system type
|
||||
- `database_version` - Version information
|
||||
- `source_format` - Original source format
|
||||
- `schemas` - Array of schema objects
|
||||
|
||||
### Schema Level
|
||||
- `name` - Schema name
|
||||
- `description` - Schema description
|
||||
- `tables` - Array of table objects
|
||||
- `views` - Array of view objects
|
||||
- `sequences` - Array of sequence objects
|
||||
|
||||
### Table Level
|
||||
- `name` - Table name
|
||||
- `schema` - Schema name
|
||||
- `description` - Table description
|
||||
- `columns` - Map of column objects
|
||||
- `constraints` - Map of constraint objects
|
||||
- `indexes` - Map of index objects
|
||||
- `relationships` - Map of relationship objects
|
||||
|
||||
### Column Level
|
||||
- `name` - Column name
|
||||
- `type` - Data type
|
||||
- `length` - Type length
|
||||
- `precision`, `scale` - Numeric precision
|
||||
- `not_null` - NOT NULL flag
|
||||
- `is_primary_key` - Primary key flag
|
||||
- `auto_increment` - Auto-increment flag
|
||||
- `default` - Default value
|
||||
- `sequence` - Column order
|
||||
|
||||
### Constraint Level
|
||||
- `name` - Constraint name
|
||||
- `type` - Constraint type (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK)
|
||||
- `columns` - Constrained columns
|
||||
- `referenced_table`, `referenced_schema` - FK references
|
||||
- `referenced_columns` - Referenced columns
|
||||
- `on_delete`, `on_update` - FK actions
|
||||
|
||||
### Index Level
|
||||
- `name` - Index name
|
||||
- `columns` - Indexed columns
|
||||
- `unique` - Unique flag
|
||||
- `type` - Index type
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Version Control** - Track schema changes in git
|
||||
- **Documentation** - Human-readable schema documentation
|
||||
- **Interchange** - Standard format for tool integration
|
||||
- **Backup** - Schema backup without database access
|
||||
- **Testing** - Test data for schema validation
|
||||
- **API** - Schema information for APIs
|
||||
|
||||
## Notes
|
||||
|
||||
- Output is pretty-printed with 2-space indentation
|
||||
- Preserves all schema metadata
|
||||
- Can be round-tripped (read and write) without loss
|
||||
- Schema-agnostic format
|
||||
- Ideal for automation and tooling
|
||||
195
pkg/writers/pgsql/README.md
Normal file
195
pkg/writers/pgsql/README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# PostgreSQL Writer
|
||||
|
||||
Generates PostgreSQL DDL (Data Definition Language) SQL scripts from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The PostgreSQL Writer converts RelSpec's internal database model representation into PostgreSQL-compatible SQL DDL scripts, including CREATE TABLE statements, constraints, indexes, views, and sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates complete PostgreSQL DDL
|
||||
- Creates schemas, tables, columns
|
||||
- Defines constraints (PK, FK, unique, check)
|
||||
- Creates indexes
|
||||
- Generates views and sequences
|
||||
- Supports migration scripts
|
||||
- Includes audit triggers (optional)
|
||||
- Handles PostgreSQL-specific data types
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.sql",
|
||||
}
|
||||
|
||||
writer := pgsql.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate PostgreSQL DDL from JSON schema
|
||||
relspec --input json \
|
||||
--in-file schema.json \
|
||||
--output pgsql \
|
||||
--out-file schema.sql
|
||||
|
||||
# Convert GORM models to PostgreSQL DDL
|
||||
relspec --input gorm \
|
||||
--in-file models.go \
|
||||
--output pgsql \
|
||||
--out-file create_tables.sql
|
||||
|
||||
# Export live database schema to SQL
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/source_db" \
|
||||
--output pgsql \
|
||||
--out-file backup_schema.sql
|
||||
```
|
||||
|
||||
## Generated SQL Example
|
||||
|
||||
```sql
|
||||
-- Schema: public
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS public;
|
||||
|
||||
-- Table: public.users
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
bio TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Constraints for public.users
|
||||
|
||||
ALTER TABLE public.users
|
||||
ADD CONSTRAINT uq_users_username UNIQUE (username);
|
||||
|
||||
-- Indexes for public.users
|
||||
|
||||
CREATE INDEX idx_users_email ON public.users (email);
|
||||
|
||||
-- Table: public.posts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.posts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Foreign Keys for public.posts
|
||||
|
||||
ALTER TABLE public.posts
|
||||
ADD CONSTRAINT fk_posts_user_id
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES public.users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION;
|
||||
|
||||
-- Indexes for public.posts
|
||||
|
||||
CREATE INDEX idx_posts_user_id ON public.posts (user_id);
|
||||
```
|
||||
|
||||
## Writer Options
|
||||
|
||||
### Metadata Options
|
||||
|
||||
```go
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.sql",
|
||||
Metadata: map[string]interface{}{
|
||||
"include_drop": true, // Include DROP statements
|
||||
"include_audit": true, // Include audit triggers
|
||||
"if_not_exists": true, // Use IF NOT EXISTS
|
||||
"migration_mode": false, // Generate migration script
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Full DDL Generation
|
||||
|
||||
Generates complete database structure:
|
||||
- CREATE SCHEMA statements
|
||||
- CREATE TABLE with all columns and types
|
||||
- PRIMARY KEY constraints
|
||||
- FOREIGN KEY constraints with actions
|
||||
- UNIQUE constraints
|
||||
- CHECK constraints
|
||||
- CREATE INDEX statements
|
||||
- CREATE VIEW statements
|
||||
- CREATE SEQUENCE statements
|
||||
|
||||
### Migration Mode
|
||||
|
||||
When `migration_mode` is enabled, generates migration scripts with:
|
||||
- Version tracking
|
||||
- Up/down migrations
|
||||
- Transactional DDL
|
||||
- Rollback support
|
||||
|
||||
### Audit Triggers
|
||||
|
||||
When `include_audit` is enabled, adds:
|
||||
- Created/updated timestamp triggers
|
||||
- Audit logging functionality
|
||||
- Change tracking
|
||||
|
||||
## PostgreSQL-Specific Features
|
||||
|
||||
- Serial types (SERIAL, BIGSERIAL)
|
||||
- Advanced types (UUID, JSONB, ARRAY)
|
||||
- Schema-qualified names
|
||||
- Constraint actions (CASCADE, RESTRICT, SET NULL)
|
||||
- Partial indexes
|
||||
- Function-based indexes
|
||||
- Check constraints with expressions
|
||||
|
||||
## Data Types
|
||||
|
||||
Supports all PostgreSQL data types:
|
||||
- Integer types: SMALLINT, INTEGER, BIGINT, SERIAL, BIGSERIAL
|
||||
- Numeric types: NUMERIC, DECIMAL, REAL, DOUBLE PRECISION
|
||||
- String types: VARCHAR, CHAR, TEXT
|
||||
- Date/Time: DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL
|
||||
- Boolean: BOOLEAN
|
||||
- Binary: BYTEA
|
||||
- JSON: JSON, JSONB
|
||||
- UUID: UUID
|
||||
- Network: INET, CIDR, MACADDR
|
||||
- Special: ARRAY, HSTORE
|
||||
|
||||
## Notes
|
||||
|
||||
- Generated SQL is formatted and readable
|
||||
- Comments are preserved from source schema
|
||||
- Schema names are fully qualified
|
||||
- Default values are properly quoted
|
||||
- Constraint names follow PostgreSQL conventions
|
||||
- Compatible with PostgreSQL 12+
|
||||
135
pkg/writers/prisma/README.md
Normal file
135
pkg/writers/prisma/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Prisma Writer
|
||||
|
||||
Generates Prisma schema files from database schema information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Prisma Writer converts RelSpec's internal database model representation into Prisma schema language (`.prisma` files), complete with models, fields, relationships, and attributes.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates Prisma schema syntax
|
||||
- Creates model definitions with proper field types
|
||||
- Adds Prisma attributes (@id, @unique, @default, etc.)
|
||||
- Generates relationship fields
|
||||
- Includes datasource and generator configurations
|
||||
- Maps table/column names with @map and @@map
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
)
|
||||
|
||||
func main() {
|
||||
options := &writers.WriterOptions{
|
||||
OutputPath: "schema.prisma",
|
||||
Metadata: map[string]interface{}{
|
||||
"datasource_provider": "postgresql",
|
||||
},
|
||||
}
|
||||
|
||||
writer := prisma.NewWriter(options)
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Examples
|
||||
|
||||
```bash
|
||||
# Generate Prisma schema from PostgreSQL database
|
||||
relspec --input pgsql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--output prisma \
|
||||
--out-file schema.prisma
|
||||
|
||||
# Convert GORM models to Prisma
|
||||
relspec --input gorm --in-file models.go --output prisma --out-file schema.prisma
|
||||
|
||||
# Convert JSON to Prisma schema
|
||||
relspec --input json --in-file database.json --output prisma --out-file prisma/schema.prisma
|
||||
```
|
||||
|
||||
## Generated Code Example
|
||||
|
||||
```prisma
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(50)
|
||||
email String @db.VarChar(100)
|
||||
bio String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
posts Post[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
title String @db.VarChar(200)
|
||||
content String? @db.Text
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("posts")
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Prisma Attributes
|
||||
|
||||
### Field Attributes
|
||||
- `@id` - Primary key
|
||||
- `@unique` - Unique constraint
|
||||
- `@default()` - Default value
|
||||
- `@map()` - Column name mapping
|
||||
- `@db.*` - Database-specific types
|
||||
- `@relation()` - Relationship definition
|
||||
|
||||
### Model Attributes
|
||||
- `@@map()` - Table name mapping
|
||||
- `@@unique()` - Composite unique constraints
|
||||
- `@@index()` - Index definitions
|
||||
- `@@id()` - Composite primary keys
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | Prisma Type | Database Type |
|
||||
|----------|-------------|---------------|
|
||||
| bigint | Int | @db.BigInt |
|
||||
| integer | Int | - |
|
||||
| varchar(n) | String | @db.VarChar(n) |
|
||||
| text | String | @db.Text |
|
||||
| boolean | Boolean | - |
|
||||
| timestamp | DateTime | @db.Timestamp |
|
||||
| uuid | String | @db.Uuid |
|
||||
| json | Json | - |
|
||||
|
||||
## Notes
|
||||
|
||||
- Model names are PascalCase (e.g., `User`, `Post`)
|
||||
- Field names are camelCase with `@map` for snake_case columns
|
||||
- Table names use `@@map` when different from model name
|
||||
- Nullable fields are marked with `?`
|
||||
- Relationship fields are automatically generated
|
||||
- Datasource provider defaults to `postgresql`
|
||||
551
pkg/writers/prisma/writer.go
Normal file
551
pkg/writers/prisma/writer.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package prisma
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// Writer implements the writers.Writer interface for Prisma schema format
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
}
|
||||
|
||||
// NewWriter creates a new Prisma writer with the given options
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
return &Writer{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteDatabase writes a Database model to Prisma schema format
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
content := w.databaseToPrisma(db)
|
||||
|
||||
if w.options.OutputPath != "" {
|
||||
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
fmt.Print(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSchema writes a Schema model to Prisma schema format
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
// Create temporary database for schema
|
||||
db := models.InitDatabase("database")
|
||||
db.Schemas = []*models.Schema{schema}
|
||||
|
||||
return w.WriteDatabase(db)
|
||||
}
|
||||
|
||||
// WriteTable writes a Table model to Prisma schema format
|
||||
func (w *Writer) WriteTable(table *models.Table) error {
|
||||
// Create temporary schema and database for table
|
||||
schema := models.InitSchema(table.Schema)
|
||||
schema.Tables = []*models.Table{table}
|
||||
|
||||
return w.WriteSchema(schema)
|
||||
}
|
||||
|
||||
// databaseToPrisma converts a Database to Prisma schema format string
|
||||
func (w *Writer) databaseToPrisma(db *models.Database) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Write datasource block
|
||||
sb.WriteString(w.generateDatasource(db))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Write generator block
|
||||
sb.WriteString(w.generateGenerator())
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Process all schemas (typically just one in Prisma)
|
||||
for _, schema := range db.Schemas {
|
||||
// Write enums
|
||||
if len(schema.Enums) > 0 {
|
||||
for _, enum := range schema.Enums {
|
||||
sb.WriteString(w.enumToPrisma(enum))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Identify join tables for implicit M2M
|
||||
joinTables := w.identifyJoinTables(schema)
|
||||
|
||||
// Write models (excluding join tables)
|
||||
for _, table := range schema.Tables {
|
||||
if joinTables[table.Name] {
|
||||
continue // Skip join tables
|
||||
}
|
||||
sb.WriteString(w.tableToPrisma(table, schema, joinTables))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// generateDatasource generates the datasource block
|
||||
func (w *Writer) generateDatasource(db *models.Database) string {
|
||||
provider := "postgresql"
|
||||
|
||||
// Map database type to Prisma provider
|
||||
switch db.DatabaseType {
|
||||
case models.PostgresqlDatabaseType:
|
||||
provider = "postgresql"
|
||||
case models.MSSQLDatabaseType:
|
||||
provider = "sqlserver"
|
||||
case models.SqlLiteDatabaseType:
|
||||
provider = "sqlite"
|
||||
case "mysql":
|
||||
provider = "mysql"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`datasource db {
|
||||
provider = "%s"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
`, provider)
|
||||
}
|
||||
|
||||
// generateGenerator generates the generator block
|
||||
func (w *Writer) generateGenerator() string {
|
||||
return `generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// enumToPrisma converts an Enum to Prisma enum block
|
||||
func (w *Writer) enumToPrisma(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name))
|
||||
for _, value := range enum.Values {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", value))
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// identifyJoinTables identifies tables that are join tables for M2M relations
|
||||
func (w *Writer) identifyJoinTables(schema *models.Schema) map[string]bool {
|
||||
joinTables := make(map[string]bool)
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Check if this is a join table:
|
||||
// 1. Starts with _ (Prisma convention)
|
||||
// 2. Has exactly 2 FK constraints
|
||||
// 3. Has composite PK with those 2 columns
|
||||
// 4. Has no other columns except the FK columns
|
||||
|
||||
if !strings.HasPrefix(table.Name, "_") {
|
||||
continue
|
||||
}
|
||||
|
||||
fks := table.GetForeignKeys()
|
||||
if len(fks) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if columns are only the FK columns
|
||||
if len(table.Columns) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if both FK columns are part of PK
|
||||
pkCols := 0
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkCols++
|
||||
}
|
||||
}
|
||||
|
||||
if pkCols == 2 {
|
||||
joinTables[table.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
return joinTables
|
||||
}
|
||||
|
||||
// tableToPrisma converts a Table to Prisma model block
|
||||
func (w *Writer) tableToPrisma(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("model %s {\n", table.Name))
|
||||
|
||||
// Collect columns to write
|
||||
columns := make([]*models.Column, 0, len(table.Columns))
|
||||
for _, col := range table.Columns {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
// Sort columns for consistent output
|
||||
sort.Slice(columns, func(i, j int) bool {
|
||||
return columns[i].Name < columns[j].Name
|
||||
})
|
||||
|
||||
// Write scalar fields
|
||||
for _, col := range columns {
|
||||
// Skip if this column is part of a relation that will be output as array field
|
||||
if w.isRelationColumn(col, table) {
|
||||
// We'll output this with the relation field
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(w.columnToField(col, table, schema))
|
||||
}
|
||||
|
||||
// Write relation fields
|
||||
sb.WriteString(w.generateRelationFields(table, schema, joinTables))
|
||||
|
||||
// Write block attributes (@@id, @@unique, @@index)
|
||||
sb.WriteString(w.generateBlockAttributes(table))
|
||||
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// columnToField converts a Column to a Prisma field definition
|
||||
func (w *Writer) columnToField(col *models.Column, table *models.Table, schema *models.Schema) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Field name
|
||||
sb.WriteString(fmt.Sprintf(" %s", col.Name))
|
||||
|
||||
// Field type
|
||||
prismaType := w.sqlTypeToPrisma(col.Type, schema)
|
||||
sb.WriteString(fmt.Sprintf(" %s", prismaType))
|
||||
|
||||
// Optional modifier
|
||||
if !col.NotNull && !col.IsPrimaryKey {
|
||||
sb.WriteString("?")
|
||||
}
|
||||
|
||||
// Field attributes
|
||||
attributes := w.generateFieldAttributes(col, table)
|
||||
if attributes != "" {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(attributes)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// sqlTypeToPrisma converts SQL types to Prisma types
|
||||
func (w *Writer) sqlTypeToPrisma(sqlType string, schema *models.Schema) string {
|
||||
// Check if it's an enum
|
||||
for _, enum := range schema.Enums {
|
||||
if strings.EqualFold(sqlType, enum.Name) {
|
||||
return enum.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Standard type mapping
|
||||
typeMap := map[string]string{
|
||||
"text": "String",
|
||||
"varchar": "String",
|
||||
"character varying": "String",
|
||||
"char": "String",
|
||||
"boolean": "Boolean",
|
||||
"bool": "Boolean",
|
||||
"integer": "Int",
|
||||
"int": "Int",
|
||||
"int4": "Int",
|
||||
"bigint": "BigInt",
|
||||
"int8": "BigInt",
|
||||
"double precision": "Float",
|
||||
"float": "Float",
|
||||
"float8": "Float",
|
||||
"decimal": "Decimal",
|
||||
"numeric": "Decimal",
|
||||
"timestamp": "DateTime",
|
||||
"timestamptz": "DateTime",
|
||||
"date": "DateTime",
|
||||
"jsonb": "Json",
|
||||
"json": "Json",
|
||||
"bytea": "Bytes",
|
||||
}
|
||||
|
||||
for sqlPattern, prismaType := range typeMap {
|
||||
if strings.Contains(strings.ToLower(sqlType), sqlPattern) {
|
||||
return prismaType
|
||||
}
|
||||
}
|
||||
|
||||
// Default to String for unknown types
|
||||
return "String"
|
||||
}
|
||||
|
||||
// generateFieldAttributes generates field attributes like @id, @unique, @default
|
||||
func (w *Writer) generateFieldAttributes(col *models.Column, table *models.Table) string {
|
||||
attrs := make([]string, 0)
|
||||
|
||||
// @id
|
||||
if col.IsPrimaryKey {
|
||||
// Check if this is part of a composite key
|
||||
pkCount := 0
|
||||
for _, c := range table.Columns {
|
||||
if c.IsPrimaryKey {
|
||||
pkCount++
|
||||
}
|
||||
}
|
||||
if pkCount == 1 {
|
||||
attrs = append(attrs, "@id")
|
||||
}
|
||||
}
|
||||
|
||||
// @unique
|
||||
if w.hasUniqueConstraint(col.Name, table) {
|
||||
attrs = append(attrs, "@unique")
|
||||
}
|
||||
|
||||
// @default
|
||||
if col.AutoIncrement {
|
||||
attrs = append(attrs, "@default(autoincrement())")
|
||||
} else if col.Default != nil {
|
||||
defaultAttr := w.formatDefaultValue(col.Default)
|
||||
if defaultAttr != "" {
|
||||
attrs = append(attrs, fmt.Sprintf("@default(%s)", defaultAttr))
|
||||
}
|
||||
}
|
||||
|
||||
// @updatedAt (check comment)
|
||||
if strings.Contains(col.Comment, "@updatedAt") {
|
||||
attrs = append(attrs, "@updatedAt")
|
||||
}
|
||||
|
||||
return strings.Join(attrs, " ")
|
||||
}
|
||||
|
||||
// formatDefaultValue formats a default value for Prisma
|
||||
func (w *Writer) formatDefaultValue(defaultValue any) string {
|
||||
switch v := defaultValue.(type) {
|
||||
case string:
|
||||
if v == "now()" {
|
||||
return "now()"
|
||||
} else if v == "gen_random_uuid()" {
|
||||
return "uuid()"
|
||||
} else if strings.Contains(strings.ToLower(v), "uuid") {
|
||||
return "uuid()"
|
||||
} else {
|
||||
// String literal
|
||||
return fmt.Sprintf(`"%s"`, v)
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int, int64, int32:
|
||||
return fmt.Sprintf("%v", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// hasUniqueConstraint checks if a column has a unique constraint
|
||||
func (w *Writer) hasUniqueConstraint(colName string, table *models.Table) bool {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint &&
|
||||
len(constraint.Columns) == 1 &&
|
||||
constraint.Columns[0] == colName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRelationColumn checks if a column is a FK column
|
||||
func (w *Writer) isRelationColumn(col *models.Column, table *models.Table) bool {
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
for _, fkCol := range constraint.Columns {
|
||||
if fkCol == col.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateRelationFields generates relation fields and their FK columns
|
||||
func (w *Writer) generateRelationFields(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Get all FK constraints
|
||||
fks := table.GetForeignKeys()
|
||||
|
||||
for _, fk := range fks {
|
||||
// Generate the FK scalar field
|
||||
for _, fkCol := range fk.Columns {
|
||||
if col, exists := table.Columns[fkCol]; exists {
|
||||
sb.WriteString(w.columnToField(col, table, schema))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the relation field
|
||||
relationType := fk.ReferencedTable
|
||||
isOptional := false
|
||||
|
||||
// Check if FK column is nullable
|
||||
for _, fkCol := range fk.Columns {
|
||||
if col, exists := table.Columns[fkCol]; exists {
|
||||
if !col.NotNull {
|
||||
isOptional = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relationName := relationType
|
||||
if strings.HasSuffix(strings.ToLower(relationName), "s") {
|
||||
relationName = relationName[:len(relationName)-1]
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s", strings.ToLower(relationName), relationType))
|
||||
|
||||
if isOptional {
|
||||
sb.WriteString("?")
|
||||
}
|
||||
|
||||
// @relation attribute
|
||||
relationAttr := w.generateRelationAttribute(fk)
|
||||
if relationAttr != "" {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(relationAttr)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Generate inverse relations (arrays) for tables that reference this one
|
||||
sb.WriteString(w.generateInverseRelations(table, schema, joinTables))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// generateRelationAttribute generates the @relation(...) attribute
|
||||
func (w *Writer) generateRelationAttribute(fk *models.Constraint) string {
|
||||
parts := make([]string, 0)
|
||||
|
||||
// fields
|
||||
fieldsStr := strings.Join(fk.Columns, ", ")
|
||||
parts = append(parts, fmt.Sprintf("fields: [%s]", fieldsStr))
|
||||
|
||||
// references
|
||||
referencesStr := strings.Join(fk.ReferencedColumns, ", ")
|
||||
parts = append(parts, fmt.Sprintf("references: [%s]", referencesStr))
|
||||
|
||||
// onDelete
|
||||
if fk.OnDelete != "" {
|
||||
parts = append(parts, fmt.Sprintf("onDelete: %s", fk.OnDelete))
|
||||
}
|
||||
|
||||
// onUpdate
|
||||
if fk.OnUpdate != "" {
|
||||
parts = append(parts, fmt.Sprintf("onUpdate: %s", fk.OnUpdate))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("@relation(%s)", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// generateInverseRelations generates array fields for reverse relationships
|
||||
func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Find all tables that have FKs pointing to this table
|
||||
for _, otherTable := range schema.Tables {
|
||||
if otherTable.Name == table.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a join table
|
||||
if joinTables[otherTable.Name] {
|
||||
// Handle implicit M2M
|
||||
if w.isJoinTableFor(otherTable, table.Name) {
|
||||
// Find the other side of the M2M
|
||||
for _, fk := range otherTable.GetForeignKeys() {
|
||||
if fk.ReferencedTable != table.Name {
|
||||
// This is the other side
|
||||
otherSide := fk.ReferencedTable
|
||||
sb.WriteString(fmt.Sprintf(" %ss %s[]\n",
|
||||
strings.ToLower(otherSide), otherSide))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular one-to-many inverse relation
|
||||
for _, fk := range otherTable.GetForeignKeys() {
|
||||
if fk.ReferencedTable == table.Name {
|
||||
// This table is referenced by otherTable
|
||||
pluralName := otherTable.Name
|
||||
if !strings.HasSuffix(pluralName, "s") {
|
||||
pluralName += "s"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s[]\n",
|
||||
strings.ToLower(pluralName), otherTable.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isJoinTableFor checks if a table is a join table involving the specified model
|
||||
func (w *Writer) isJoinTableFor(joinTable *models.Table, modelName string) bool {
|
||||
for _, fk := range joinTable.GetForeignKeys() {
|
||||
if fk.ReferencedTable == modelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateBlockAttributes generates block-level attributes like @@id, @@unique, @@index
|
||||
func (w *Writer) generateBlockAttributes(table *models.Table) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// @@id for composite primary key
|
||||
pkCols := make([]string, 0)
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkCols = append(pkCols, col.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkCols) > 1 {
|
||||
sort.Strings(pkCols)
|
||||
sb.WriteString(fmt.Sprintf(" @@id([%s])\n", strings.Join(pkCols, ", ")))
|
||||
}
|
||||
|
||||
// @@unique for multi-column unique constraints
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
|
||||
sb.WriteString(fmt.Sprintf(" @@unique([%s])\n", strings.Join(constraint.Columns, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// @@index for indexes
|
||||
for _, index := range table.Indexes {
|
||||
if !index.Unique { // Unique indexes are handled by @@unique
|
||||
sb.WriteString(fmt.Sprintf(" @@index([%s])\n", strings.Join(index.Columns, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user