Skip to content

Commit 84ba3ff

Browse files
committed
refactor: parameterize MySQL queries, add CI/CD pipeline, update README for multi-DB support
1 parent 679408c commit 84ba3ff

6 files changed

Lines changed: 162 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
branches: [ main ]
88

99
jobs:
10-
build:
10+
unit-tests:
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v4
@@ -23,7 +23,7 @@ jobs:
2323
- name: Build
2424
run: go build -v ./...
2525

26-
- name: Test
26+
- name: Unit Tests
2727
run: go test -v -race -coverprofile=coverage.out ./...
2828

2929
- name: Check coverage
@@ -46,3 +46,87 @@ jobs:
4646
with:
4747
files: ./coverage.out
4848
flags: unittests
49+
50+
integration-tests:
51+
runs-on: ubuntu-latest
52+
needs: unit-tests
53+
services:
54+
postgres-source:
55+
image: postgres:16-alpine
56+
env:
57+
POSTGRES_USER: testuser
58+
POSTGRES_PASSWORD: testpass
59+
POSTGRES_DB: sourcedb
60+
ports:
61+
- 5432:5432
62+
options: >-
63+
--health-cmd pg_isready
64+
--health-interval 10s
65+
--health-timeout 5s
66+
--health-retries 5
67+
68+
postgres-target:
69+
image: postgres:16-alpine
70+
env:
71+
POSTGRES_USER: testuser
72+
POSTGRES_PASSWORD: testpass
73+
POSTGRES_DB: targetdb
74+
ports:
75+
- 5433:5432
76+
options: >-
77+
--health-cmd pg_isready
78+
--health-interval 10s
79+
--health-timeout 5s
80+
--health-retries 5
81+
82+
mysql-source:
83+
image: mysql:8.0
84+
env:
85+
MYSQL_ROOT_PASSWORD: rootpass
86+
MYSQL_DATABASE: sourcedb
87+
MYSQL_USER: testuser
88+
MYSQL_PASSWORD: testpass
89+
ports:
90+
- 3306:3306
91+
options: >-
92+
--health-cmd "mysqladmin ping -h 127.0.0.1"
93+
--health-interval 10s
94+
--health-timeout 5s
95+
--health-retries 10
96+
97+
mysql-target:
98+
image: mysql:8.0
99+
env:
100+
MYSQL_ROOT_PASSWORD: rootpass
101+
MYSQL_DATABASE: targetdb
102+
MYSQL_USER: testuser
103+
MYSQL_PASSWORD: testpass
104+
ports:
105+
- 3307:3306
106+
options: >-
107+
--health-cmd "mysqladmin ping -h 127.0.0.1"
108+
--health-interval 10s
109+
--health-timeout 5s
110+
--health-retries 10
111+
112+
steps:
113+
- uses: actions/checkout@v4
114+
115+
- name: Set up Go
116+
uses: actions/setup-go@v5
117+
with:
118+
go-version: '1.21'
119+
120+
- name: Download dependencies
121+
run: go mod download
122+
123+
- name: Wait for services
124+
run: sleep 15
125+
126+
- name: Run Integration Tests
127+
run: go run -tags=integration test/integration_main.go
128+
env:
129+
PG_SOURCE_DSN: "postgres://testuser:testpass@localhost:5432/sourcedb?sslmode=disable"
130+
PG_TARGET_DSN: "postgres://testuser:testpass@localhost:5433/targetdb?sslmode=disable"
131+
MYSQL_SOURCE_DSN: "testuser:testpass@tcp(localhost:3306)/sourcedb?multiStatements=true"
132+
MYSQL_TARGET_DSN: "testuser:testpass@tcp(localhost:3307)/targetdb?multiStatements=true"

README.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
[![Coverage](https://codecov.io/gh/meru143/dbdiff/branch/main/graph/badge.svg)](https://codecov.io/gh/meru143/dbdiff)
66
[![Go Version](https://img.shields.io/github/go-mod/go-version/meru143/dbdiff)](https://github.com/meru143/dbdiff)
77

8-
PostgreSQL schema comparison and migration CLI tool.
8+
Multi-database schema comparison and migration CLI tool. Supports **PostgreSQL** and **MySQL**.
99

1010
## Features
1111

12-
- Compare PostgreSQL schemas between two databases
12+
- Compare database schemas between two instances
1313
- Generate migration SQL scripts
14+
- **Multi-database support**: PostgreSQL and MySQL
15+
- Dialect-aware SQL generation (e.g., `MODIFY COLUMN` for MySQL, `ALTER COLUMN TYPE` for PostgreSQL)
1416
- Multiple output formats: SQL, Table, JSON
1517
- Dry-run mode (default)
1618
- Schema filtering
1719
- Ignore patterns for columns
1820
- Transaction wrapper support
1921
- SSL/TLS connection support
22+
- Migration tracking
2023

2124
## Installation
2225

@@ -34,16 +37,32 @@ chmod +x dbdiff
3437

3538
## Usage
3639

40+
### PostgreSQL
41+
3742
```bash
38-
# Compare two databases
43+
# Compare two PostgreSQL databases
3944
dbdiff compare postgres://user:pass@localhost:5432/db1 postgres://user:pass@localhost:5432/db2
4045

4146
# Generate migration
4247
dbdiff migrate -s postgres://localhost:5432/db1 -t postgres://localhost:5432/db2 -o migration.sql
4348

4449
# Show table diff
4550
dbdiff diff postgres://localhost:5432/db1 postgres://localhost:5432/db2 --format table
51+
```
52+
53+
### MySQL
54+
55+
```bash
56+
# Compare two MySQL databases
57+
dbdiff compare mysql://user:pass@tcp(localhost:3306)/db1 mysql://user:pass@tcp(localhost:3306)/db2
58+
59+
# Generate migration
60+
dbdiff migrate -s mysql://user:pass@tcp(localhost:3306)/db1 -t mysql://user:pass@tcp(localhost:3306)/db2 -o migration.sql
61+
```
62+
63+
### General Commands
4664

65+
```bash
4766
# List tables
4867
dbdiff tables postgres://localhost:5432/db
4968

@@ -66,6 +85,13 @@ ignore_patterns:
6685
- "_updated_at"
6786
```
6887
88+
## Supported Databases
89+
90+
| Database | Introspection | Migration SQL | Dialect-Aware DDL |
91+
|------------|:---:|:---:|:---:|
92+
| PostgreSQL | ✅ | ✅ | ✅ |
93+
| MySQL 8.0+ | ✅ | ✅ | ✅ |
94+
6995
## Flags
7096
7197
| Flag | Short | Description | Default |
@@ -74,7 +100,7 @@ ignore_patterns:
74100
| --target | -t | Target database URL | |
75101
| --output | -o | Output file path | stdout |
76102
| --format | | Output format: sql, table, json | sql |
77-
| --schema | | PostgreSQL schema | public |
103+
| --schema | | Database schema | public |
78104
| --dry-run | | Dry-run mode (don't write) | true |
79105
| --force | -f | Skip confirmation prompt | false |
80106
| --timeout | | Query timeout | 30s |
@@ -97,6 +123,20 @@ ignore_patterns:
97123
| DBDIFF_LOG_LEVEL | Log level: debug, info, warn, error |
98124
| DBDIFF_CONFIG | Config file path |
99125
126+
## Development
127+
128+
### Running Tests
129+
130+
```bash
131+
# Unit tests
132+
go test ./...
133+
134+
# Integration tests (requires Docker)
135+
docker compose -f docker-compose.test.yml up -d
136+
go run -tags=integration test/integration_main.go
137+
docker compose -f docker-compose.test.yml down
138+
```
139+
100140
## License
101141

102142
MIT License - see [LICENSE](LICENSE)

internal/db/mysql/mysql.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,12 @@ func (d *Driver) Dialect() string {
8585
// Introspect performs full schema introspection for MySQL
8686
func (d *Driver) Introspect(ctx context.Context, schema string, ignorePatterns []string) (*types.Schema, error) {
8787
if schema == "" {
88-
// In MySQL, schema is database
89-
schema = "DATABASE()" // Or get current DB
88+
// Query the current database name
89+
var dbName string
90+
if err := d.db.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&dbName); err != nil {
91+
return nil, fmt.Errorf("failed to determine current database: %w", err)
92+
}
93+
schema = dbName
9094
}
9195

9296
result := &types.Schema{}

internal/db/mysql/mysql_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,10 @@ func TestIntrospectMocked(t *testing.T) {
177177
ctx := context.Background()
178178
schemaName := "test_schema"
179179

180-
// Mock getTables
180+
// Mock getTables (ListTables now takes schema as param)
181181
tableRows := sqlmock.NewRows([]string{"TABLE_NAME"}).AddRow("users")
182182
mock.ExpectQuery(`SELECT table_name FROM information_schema\.tables`).
183+
WithArgs(schemaName).
183184
WillReturnRows(tableRows)
184185

185186
// Mock getColumns
@@ -189,37 +190,38 @@ func TestIntrospectMocked(t *testing.T) {
189190
AddRow("email", "varchar", nil, "YES", 0)
190191

191192
mock.ExpectQuery(`SELECT c\.COLUMN_NAME, c\.DATA_TYPE`).
192-
WithArgs("users").
193+
WithArgs(schemaName, "users").
193194
WillReturnRows(colRows)
194195

195196
// Mock getIndexes
196197
idxRows := sqlmock.NewRows([]string{"INDEX_NAME", "is_unique", "is_primary", "COLUMN_NAME"}).
197198
AddRow("idx_email", false, false, "email")
198199
mock.ExpectQuery(`SELECT INDEX_NAME, NON_UNIQUE \= 0 AS is_unique, INDEX_NAME \= 'PRIMARY' AS is_primary, COLUMN_NAME`).
199-
WithArgs("users").
200+
WithArgs(schemaName, "users").
200201
WillReturnRows(idxRows)
201202

202203
// Mock getConstraints
203204
cstRows := sqlmock.NewRows([]string{"CONSTRAINT_NAME", "CONSTRAINT_TYPE", "COLUMN_NAME"}).
204205
AddRow("chk_age", "CHECK", "age")
205206
mock.ExpectQuery(`SELECT tc\.CONSTRAINT_NAME, tc\.CONSTRAINT_TYPE, kcu\.COLUMN_NAME`).
206-
WithArgs("users").
207+
WithArgs(schemaName, "users").
207208
WillReturnRows(cstRows)
208209

209210
// Mock getForeignKeys
210211
fkRows := sqlmock.NewRows([]string{"CONSTRAINT_NAME", "COLUMN_NAME", "REFERENCED_TABLE_NAME", "REFERENCED_COLUMN_NAME"}).
211212
AddRow("fk_user", "user_id", "users", "id")
212213
mock.ExpectQuery(`SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME`).
213-
WithArgs("users").
214+
WithArgs(schemaName, "users").
214215
WillReturnRows(fkRows)
215216

216217
mock.ExpectQuery(`SELECT UPDATE_RULE, DELETE_RULE`).
217-
WithArgs("fk_user").
218+
WithArgs(schemaName, "fk_user").
218219
WillReturnRows(sqlmock.NewRows([]string{"UPDATE_RULE", "DELETE_RULE"}).AddRow("RESTRICT", "CASCADE"))
219220

220221
// Mock getViews
221222
viewRows := sqlmock.NewRows([]string{"TABLE_NAME", "VIEW_DEFINITION"}).AddRow("v_users", "SELECT * FROM users")
222223
mock.ExpectQuery(`SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS`).
224+
WithArgs(schemaName).
223225
WillReturnRows(viewRows)
224226

225227
schema, err := d.Introspect(ctx, schemaName, nil)

internal/db/mysql/queries.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ var DefaultIgnorePatterns = []string{"_created_at", "_updated_at", "_modified_at
1515
// ListTables returns table names in the given schema
1616
func (d *Driver) ListTables(ctx context.Context, schema string, ignorePatterns []string) ([]string, error) {
1717
// In MySQL, the schema is often just the currently selected DB
18-
query := "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'"
19-
rows, err := d.db.QueryContext(ctx, query)
18+
query := "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'"
19+
rows, err := d.db.QueryContext(ctx, query, schema)
2020
if err != nil {
2121
return nil, err
2222
}
@@ -104,9 +104,9 @@ func getColumns(ctx context.Context, dbConn *sql.DB, schemaName, tableName strin
104104
AND c.TABLE_NAME = k.TABLE_NAME
105105
AND c.COLUMN_NAME = k.COLUMN_NAME
106106
AND k.CONSTRAINT_NAME = 'PRIMARY'
107-
WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ?
107+
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?
108108
ORDER BY c.ORDINAL_POSITION`
109-
rows, err := dbConn.QueryContext(ctx, query, tableName)
109+
rows, err := dbConn.QueryContext(ctx, query, schemaName, tableName)
110110
if err != nil {
111111
return nil, err
112112
}
@@ -140,10 +140,10 @@ func getIndexes(ctx context.Context, dbConn *sql.DB, schemaName, tableName strin
140140
INDEX_NAME = 'PRIMARY' AS is_primary,
141141
COLUMN_NAME
142142
FROM information_schema.STATISTICS
143-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
143+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
144144
ORDER BY INDEX_NAME, SEQ_IN_INDEX`
145145

146-
rows, err := dbConn.QueryContext(ctx, query, tableName)
146+
rows, err := dbConn.QueryContext(ctx, query, schemaName, tableName)
147147
if err != nil {
148148
return nil, err
149149
}
@@ -193,10 +193,10 @@ func getConstraints(ctx context.Context, dbConn *sql.DB, schemaName, tableName s
193193
FROM information_schema.TABLE_CONSTRAINTS tc
194194
JOIN information_schema.KEY_COLUMN_USAGE kcu
195195
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
196-
WHERE tc.TABLE_SCHEMA = DATABASE() AND tc.TABLE_NAME = ? AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'CHECK')
196+
WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ? AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'CHECK')
197197
ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION`
198198

199-
rows, err := dbConn.QueryContext(ctx, query, tableName)
199+
rows, err := dbConn.QueryContext(ctx, query, schemaName, tableName)
200200
if err != nil {
201201
return nil, err
202202
}
@@ -239,10 +239,10 @@ func getForeignKeys(ctx context.Context, dbConn *sql.DB, schemaName, tableName s
239239
REFERENCED_TABLE_NAME,
240240
REFERENCED_COLUMN_NAME
241241
FROM information_schema.KEY_COLUMN_USAGE
242-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
242+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
243243
ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`
244244

245-
rows, err := dbConn.QueryContext(ctx, query, tableName)
245+
rows, err := dbConn.QueryContext(ctx, query, schemaName, tableName)
246246
if err != nil {
247247
return nil, err
248248
}
@@ -274,9 +274,9 @@ func getForeignKeys(ctx context.Context, dbConn *sql.DB, schemaName, tableName s
274274
var fks []types.ForeignKey
275275
for _, name := range orderedNames {
276276
// fetch on update / delete
277-
rcQuery := `SELECT UPDATE_RULE, DELETE_RULE FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = ?`
277+
rcQuery := `SELECT UPDATE_RULE, DELETE_RULE FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = ? AND CONSTRAINT_NAME = ?`
278278
var onUpd, onDel string
279-
if err := dbConn.QueryRowContext(ctx, rcQuery, name).Scan(&onUpd, &onDel); err == nil {
279+
if err := dbConn.QueryRowContext(ctx, rcQuery, schemaName, name).Scan(&onUpd, &onDel); err == nil {
280280
fkMap[name].OnUpdate = onUpd
281281
fkMap[name].OnDelete = onDel
282282
}
@@ -287,8 +287,8 @@ func getForeignKeys(ctx context.Context, dbConn *sql.DB, schemaName, tableName s
287287
}
288288

289289
func getViews(ctx context.Context, dbConn *sql.DB, schemaName string) ([]types.View, error) {
290-
query := `SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE()`
291-
rows, err := dbConn.QueryContext(ctx, query)
290+
query := `SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS WHERE TABLE_SCHEMA = ?`
291+
rows, err := dbConn.QueryContext(ctx, query, schemaName)
292292
if err != nil {
293293
return nil, err
294294
}

internal/db/mysql/queries_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,12 @@ func TestListTables(t *testing.T) {
7878
AddRow("posts").
7979
AddRow("_created_at") // This should be ignored by DefaultIgnorePatterns
8080

81-
// The query uses DATABASE() and 'BASE TABLE'
82-
mock.ExpectQuery(`SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE\(\) AND table_type = 'BASE TABLE'`).
81+
// The query now uses parameterized schema
82+
mock.ExpectQuery(`SELECT table_name FROM information_schema.tables WHERE table_schema = \? AND table_type = 'BASE TABLE'`).
83+
WithArgs("testdb").
8384
WillReturnRows(rows)
8485

85-
tables, err := d.ListTables(ctx, "testdb", DefaultIgnorePatterns) // schema arg is ignored in query string anyway
86+
tables, err := d.ListTables(ctx, "testdb", DefaultIgnorePatterns)
8687
if err != nil {
8788
t.Errorf("unexpected error: %s", err)
8889
}

0 commit comments

Comments
 (0)