Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 54 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,60 @@ jobs:
- name: Build
run: go build ./cmd/gorm-schema

test-postgresql:
name: Test PostgreSQL
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [1.24.x]

services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: gorm_schema_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true

- name: Install dependencies
run: go mod tidy

- name: Wait for PostgreSQL
run: |
until pg_isready -h localhost -p 5432 -U postgres; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done

- name: Run PostgreSQL tests
env:
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gorm_schema_test
run: |
go test -v -run "TestPostgreSQL" ./tests/migration/diff/

- name: Build
run: go build ./cmd/gorm-schema

lint:
name: Lint
runs-on: ubuntu-latest
Expand All @@ -52,20 +106,3 @@ jobs:

- name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --enable=govet,staticcheck --disable=errcheck,ineffassign,unused --timeout=5m ./...

# security:
# name: Security Scan
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4

# - name: Set up Go
# uses: actions/setup-go@v5
# with:
# go-version: "1.21"
# cache: true

# - name: Run gosec
# uses: securego/gosec@master
# with:
# args: ./...
9 changes: 0 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,6 @@ lint:
fi
golangci-lint run --enable=govet,staticcheck --disable=errcheck,ineffassign,unused --timeout=5m ./...

# Run security checks
security-check:
@echo "Running security checks..."
@if ! command -v gosec >/dev/null 2>&1; then \
echo "Installing gosec..."; \
go install github.com/securego/gosec/v2/cmd/gosec@latest; \
fi
gosec ./...

# Show help
help:
Expand All @@ -88,7 +80,6 @@ help:
@echo " make test - Run tests"
@echo " make deps - Install dependencies"
@echo " make lint - Run linters"
@echo " make security-check - Run security checks"
@echo " make migrate-create - Create a new migration (requires name=migration_name)"
@echo " make migrate-up - Apply pending migrations"
@echo " make migrate-down - Rollback the last migration"
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,10 @@ func main() {
}
```


### 4. Generate your model registry

Use the register command to automatically scan your models directory (e.g., models/) and generate a models_registry.go file.


```bash
go run cmd/migration/main.go register [path/to/models]
```
Expand Down Expand Up @@ -227,10 +225,6 @@ make lint

## Limitations

- **Index changes (add/drop/modify) are only guaranteed for new tables.**
- If you add, remove, or modify indexes on existing tables, these changes may not be automatically generated in migration files. You must add such index changes manually to your migrations.
- **Foreign key diffs are currently ignored.**
- Changes to foreign key constraints (add/drop/modify) are not detected or generated in migrations.
- **Schema comparison is model-driven.**
- Only columns present in your Go models are considered for schema diffs. Any manual changes to the database schema that are not reflected in your models will not be detected.

Expand Down
3 changes: 2 additions & 1 deletion example/models/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ type Tenant struct {
IsDeleted bool
DeletedAt *time.Time
OwnerID int
TenantType uint
ContractStart time.Time
ContractEnd time.Time
UserManagerID int
UserManager *User `gorm:"foreignKey:UserManagerID"`
}
205 changes: 205 additions & 0 deletions migration/diff/migrator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package diff

import (
"fmt"
"strings"

"gorm.io/gorm"
"gorm.io/gorm/schema"
)

type Migrator interface {
ColumnTypes(dst interface{}) ([]gorm.ColumnType, error)
GetTables() ([]string, error)
GetIndexes(tableName string) ([]*schema.Index, error)
GetRelationships(tableName string) ([]*schema.Relationship, error)
}

type SchemaMigrator struct {
gormMigrator gorm.Migrator
db *gorm.DB
}

func NewSchemaMigrator(db *gorm.DB) Migrator {
return &SchemaMigrator{
gormMigrator: db.Migrator(),
db: db,
}
}

func (m *SchemaMigrator) ColumnTypes(dst interface{}) ([]gorm.ColumnType, error) {
return m.gormMigrator.ColumnTypes(dst)
}

func (m *SchemaMigrator) GetTables() ([]string, error) {
return m.gormMigrator.GetTables()
}

func (m *SchemaMigrator) GetIndexes(tableName string) ([]*schema.Index, error) {
// Handle empty table name
if tableName == "" {
return []*schema.Index{}, nil
}

if m.db == nil {
return []*schema.Index{}, nil
}

if m.db.Name() != "postgres" {
return []*schema.Index{}, nil
}

var indexes []*schema.Index

// Query to get index information from PostgreSQL system catalogs
query := `
SELECT
i.indexname,
ix.indisunique,
ix.indisprimary,
array_to_string(array_agg(a.attname ORDER BY t.ordinality), ',') as column_names
FROM pg_indexes i
JOIN pg_class c ON c.relname = i.tablename
JOIN pg_index ix ON ix.indexrelid = (i.schemaname||'.'||i.indexname)::regclass
JOIN unnest(ix.indkey) WITH ORDINALITY t(attnum, ordinality) ON true
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = t.attnum
WHERE i.tablename = $1
GROUP BY i.indexname, i.indexdef, ix.indisunique, ix.indisprimary;
`

rows, err := m.db.Raw(query, tableName).Rows()
if err != nil {
return nil, fmt.Errorf("failed to get indexes for table %s: %w", tableName, err)
}
defer rows.Close()

for rows.Next() {
var indexName, columnNames string
var isUnique, isPrimaryKey bool

if err := rows.Scan(&indexName, &isUnique, &isPrimaryKey, &columnNames); err != nil {
return nil, fmt.Errorf("failed to scan index row: %w", err)
}

// Parse column names
columns := strings.Split(columnNames, ",")
var fields []schema.IndexOption
for _, col := range columns {
col = strings.TrimSpace(col)
if col != "" {
fields = append(fields, schema.IndexOption{
Field: &schema.Field{DBName: col},
})
}
}

// Create index
index := &schema.Index{
Name: indexName,
Type: "BTREE", // PostgreSQL default index type
Fields: fields,
Option: func() string {
if isPrimaryKey {
return "PRIMARY KEY"
}
if isUnique {
return "UNIQUE"
}
return ""
}(),
}

indexes = append(indexes, index)
}

return indexes, nil
}

func (m *SchemaMigrator) GetRelationships(tableName string) ([]*schema.Relationship, error) {
// Handle empty table name
if tableName == "" {
return []*schema.Relationship{}, nil
}

if m.db == nil {
return []*schema.Relationship{}, nil
}

if m.db.Name() != "postgres" {
return []*schema.Relationship{}, nil
}

var relationships []*schema.Relationship

// Query to get foreign key information from PostgreSQL information_schema
query := `
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name,
ccu.table_name AS referenced_table_name,
ccu.column_name AS referenced_column_name,
rc.delete_rule AS on_delete,
rc.update_rule AS on_update
FROM
information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE
tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = $1
ORDER BY
tc.constraint_name, kcu.ordinal_position;
`

rows, err := m.db.Raw(query, tableName).Rows()
if err != nil {
return nil, fmt.Errorf("failed to get relationships for table %s: %w", tableName, err)
}
defer rows.Close()

for rows.Next() {
var constraintName, tableName, columnName, referencedTableName, referencedColumnName string
var onDelete, onUpdate string

if err := rows.Scan(&constraintName, &tableName, &columnName, &referencedTableName, &referencedColumnName, &onDelete, &onUpdate); err != nil {
return nil, fmt.Errorf("failed to scan foreign key row: %w", err)
}

// Create relationship
relationship := &schema.Relationship{
Name: constraintName,
Type: schema.BelongsTo,
Field: &schema.Field{
DBName: columnName,
Schema: &schema.Schema{
Table: tableName,
},
},
Schema: &schema.Schema{
Table: referencedTableName,
},
References: []*schema.Reference{
{
ForeignKey: &schema.Field{
DBName: columnName,
},
PrimaryKey: &schema.Field{
DBName: referencedColumnName,
},
},
},
}

relationships = append(relationships, relationship)
}

return relationships, nil
}
Loading