Skip to content

Commit 0d74942

Browse files
committed
GH-3: postgres source
1 parent df0bdf1 commit 0d74942

8 files changed

Lines changed: 533 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ go install github.com/denchenko/dberd/cmd/dberd@latest
3333

3434
Currently, DBerd supports the following database sources:
3535

36+
- **PostgreSQL**: Extract schema from PostgreSQL databases using the `postgres` source type;
3637
- **MySQL**: Extract schema from MySQL databases using the `mysql` source type;
3738
- **CockroachDB**: Extract schema from CockroachDB databases using the `cockroach` source type;
3839
- **ClickHouse**: Extract schema from ClickHouse databases using the `clickhouse` source type;

cmd/dberd/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import (
1212
"github.com/denchenko/dberd/source/cockroach"
1313
"github.com/denchenko/dberd/source/mongodb"
1414
"github.com/denchenko/dberd/source/mysql"
15+
"github.com/denchenko/dberd/source/postgres"
1516
"github.com/denchenko/dberd/target/d2"
1617
"github.com/denchenko/dberd/target/json"
1718
"github.com/denchenko/dberd/target/mermaid"
1819
"github.com/denchenko/dberd/target/plantuml"
1920
)
2021

2122
func main() {
22-
sourceType := flag.String("source", "", "Source database type (mysql, cockroach, clickhouse, mongodb)")
23+
sourceType := flag.String("source", "", "Source database type (postgres, mysql, cockroach, clickhouse, mongodb)")
2324
targetType := flag.String("target", "", "Target type (d2, plantuml, json, mermaid)")
2425
formatToFile := flag.String("format-to-file", "", "Output file for the formatted schema")
2526
renderToFile := flag.String("render-to-file", "", "Output file for the rendered diagram")
@@ -102,6 +103,8 @@ func main() {
102103

103104
func pickSource(sourceType, sourceDSN string) (dberd.Source, error) {
104105
switch sourceType {
106+
case "postgres":
107+
return postgres.NewSource(sourceDSN)
105108
case "mysql":
106109
return mysql.NewSource(sourceDSN)
107110
case "cockroach":

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/testcontainers/testcontainers-go/modules/cockroachdb v0.37.0
1313
github.com/testcontainers/testcontainers-go/modules/mongodb v0.37.0
1414
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0
15+
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
1516
go.mongodb.org/mongo-driver v1.14.0
1617
oss.terrastruct.com/d2 v0.7.0
1718
oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
122122
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
123123
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
124124
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
125+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
126+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
125127
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
126128
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
127129
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
@@ -130,6 +132,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
130132
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
131133
github.com/mazznoer/csscolorparser v0.1.5 h1:Wr4uNIE+pHWN3TqZn2SGpA2nLRG064gB7WdSfSS5cz4=
132134
github.com/mazznoer/csscolorparser v0.1.5/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I=
135+
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
136+
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
133137
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
134138
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
135139
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -195,6 +199,8 @@ github.com/testcontainers/testcontainers-go/modules/mongodb v0.37.0 h1:drGy4LJOV
195199
github.com/testcontainers/testcontainers-go/modules/mongodb v0.37.0/go.mod h1:e9/4dGJfSZW59/kXGf/ksrEvA+BqP/daax0Usp2cpsM=
196200
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
197201
github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
202+
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
203+
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
198204
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
199205
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
200206
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=

source/cockroach/cockroach_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ func TestExtractSchema(t *testing.T) {
188188
func setupTestDB(t *testing.T) (testcontainers.Container, *sql.DB) {
189189
ctx := context.Background()
190190

191-
container, err := cockroachdb.RunContainer(ctx,
192-
testcontainers.WithImage("cockroachdb/cockroach:latest-v23.1"),
191+
container, err := cockroachdb.Run(ctx,
192+
"cockroachdb/cockroach:latest-v23.1",
193193
)
194194
require.NoError(t, err)
195195

source/mongodb/mongodb_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func TestExtractSchema(t *testing.T) {
112112
func setupTestDB(t *testing.T) (testcontainers.Container, *mongo.Client) {
113113
ctx := context.Background()
114114

115-
container, err := mongodb.RunContainer(ctx)
115+
container, err := mongodb.Run(ctx, "mongo:6")
116116
require.NoError(t, err)
117117

118118
connStr, err := container.ConnectionString(ctx)

source/postgres/postgres.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Package postgres provides functionality for extracting database schema information
2+
// from PostgreSQL databases.
3+
package postgres
4+
5+
import (
6+
"context"
7+
"database/sql"
8+
"fmt"
9+
"io"
10+
"strings"
11+
12+
"github.com/denchenko/dberd"
13+
"github.com/jackc/pgx/v5"
14+
"github.com/jackc/pgx/v5/stdlib"
15+
)
16+
17+
// Ensure Source implements dberd interfaces.
18+
var (
19+
_ dberd.Source = (*Source)(nil)
20+
)
21+
22+
// Source represents a PostgreSQL database source for schema extraction.
23+
type Source struct {
24+
db *sql.DB
25+
closer io.Closer
26+
}
27+
28+
// NewSource creates a new PostgreSQL source from a connection string.
29+
func NewSource(connStr string) (*Source, error) {
30+
pgConfig, err := pgx.ParseConfig(connStr)
31+
if err != nil {
32+
return nil, fmt.Errorf("parsing postgres connection string: %w", err)
33+
}
34+
35+
pgConnector := stdlib.GetConnector(*pgConfig)
36+
db := sql.OpenDB(pgConnector)
37+
38+
return &Source{
39+
db: db,
40+
closer: db,
41+
}, nil
42+
}
43+
44+
// NewSourceFromDB creates a new PostgreSQL source from an existing database connection.
45+
// This is useful when you want to reuse an existing database connection
46+
// for schema extraction purposes.
47+
func NewSourceFromDB(db *sql.DB) *Source {
48+
return &Source{
49+
db: db,
50+
}
51+
}
52+
53+
// Close closes the database connection if it was created by NewSource.
54+
// If the connection was provided externally (via NewSourceFromDB), this is a no-op.
55+
func (s *Source) Close() error {
56+
if s.closer == nil {
57+
return nil
58+
}
59+
60+
return s.closer.Close()
61+
}
62+
63+
// ExtractSchema extracts the complete database schema including tables and their references.
64+
func (s *Source) ExtractSchema(ctx context.Context) (schema dberd.Schema, err error) {
65+
schema.Tables, err = s.extractTables(ctx)
66+
if err != nil {
67+
return dberd.Schema{}, fmt.Errorf("extracting tables: %w", err)
68+
}
69+
70+
schema.References, err = s.extractReferences(ctx)
71+
if err != nil {
72+
return dberd.Schema{}, fmt.Errorf("extracting references: %w", err)
73+
}
74+
75+
return schema, nil
76+
}
77+
78+
const extractTablesQuery = `
79+
WITH pk_columns AS (
80+
SELECT
81+
kcu.table_schema,
82+
kcu.table_name,
83+
kcu.column_name
84+
FROM information_schema.key_column_usage kcu
85+
JOIN information_schema.table_constraints tc
86+
ON tc.constraint_name = kcu.constraint_name
87+
AND tc.table_schema = kcu.table_schema
88+
WHERE tc.constraint_type = 'PRIMARY KEY'
89+
ORDER BY kcu.table_schema, kcu.table_name, kcu.column_name
90+
)
91+
SELECT
92+
c.table_schema,
93+
c.table_name,
94+
c.column_name,
95+
c.data_type,
96+
c.is_nullable,
97+
c.column_default,
98+
pgd.description as column_comment,
99+
EXISTS (
100+
SELECT 1
101+
FROM pk_columns pk
102+
WHERE pk.table_schema = c.table_schema
103+
AND pk.table_name = c.table_name
104+
AND pk.column_name = c.column_name
105+
) AS is_primary
106+
FROM information_schema.columns c
107+
JOIN information_schema.tables t ON c.table_schema = t.table_schema AND c.table_name = t.table_name
108+
LEFT JOIN pg_catalog.pg_statio_all_tables st ON st.schemaname = c.table_schema AND st.relname = c.table_name
109+
LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
110+
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
111+
AND t.table_type = 'BASE TABLE'
112+
ORDER BY c.table_schema, c.table_name, c.ordinal_position;`
113+
114+
type tableRow struct {
115+
tableSchema string
116+
tableName string
117+
columnName string
118+
dataType string
119+
isNullable string
120+
columnDefault *string
121+
columnComment *string
122+
isPrimary bool
123+
}
124+
125+
// extractTables queries the database for table and column information and converts it to dberd.Table format.
126+
// It excludes system schemas and hidden columns.
127+
func (s *Source) extractTables(ctx context.Context) ([]dberd.Table, error) {
128+
rows, err := s.db.QueryContext(ctx, extractTablesQuery)
129+
if err != nil {
130+
return nil, fmt.Errorf("querying tables: %w", err)
131+
}
132+
defer rows.Close()
133+
134+
tablesRows := make([]tableRow, 0, 100) // Assuming tables rows.
135+
136+
for rows.Next() {
137+
var r tableRow
138+
if err := rows.Scan(
139+
&r.tableSchema,
140+
&r.tableName,
141+
&r.columnName,
142+
&r.dataType,
143+
&r.isNullable,
144+
&r.columnDefault,
145+
&r.columnComment,
146+
&r.isPrimary,
147+
); err != nil {
148+
return nil, fmt.Errorf("scanning tables row: %w", err)
149+
}
150+
151+
tablesRows = append(tablesRows, r)
152+
}
153+
154+
if err := rows.Err(); err != nil {
155+
return nil, fmt.Errorf("tables rows error: %w", err)
156+
}
157+
158+
return tableRowsToSchemaTables(tablesRows), nil
159+
}
160+
161+
// tableRowsToSchemaTables converts a slice of tableRow into a slice of dberd.Table.
162+
// It groups columns by table and constructs table definitions with their columns.
163+
func tableRowsToSchemaTables(tableRows []tableRow) []dberd.Table {
164+
tableMap := make(map[string]*dberd.Table, len(tableRows)/10) // Assuming average 10 columns per table
165+
166+
for _, row := range tableRows {
167+
tableKey := row.tableSchema + "." + row.tableName
168+
169+
table, exists := tableMap[tableKey]
170+
if !exists {
171+
table = &dberd.Table{
172+
Name: tableKey,
173+
Columns: make([]dberd.Column, 0, 10),
174+
}
175+
tableMap[tableKey] = table
176+
}
177+
178+
definition := strings.ToUpper(row.dataType)
179+
if row.isNullable == "NO" {
180+
definition += " NOT NULL"
181+
}
182+
if row.columnDefault != nil && *row.columnDefault != "" {
183+
definition += " DEFAULT " + *row.columnDefault
184+
}
185+
186+
column := dberd.Column{
187+
Name: row.columnName,
188+
Definition: definition,
189+
IsPrimary: row.isPrimary,
190+
}
191+
192+
if row.columnComment != nil {
193+
column.Comment = *row.columnComment
194+
}
195+
196+
table.Columns = append(table.Columns, column)
197+
}
198+
199+
// Pre-allocate slice with exact size
200+
tables := make([]dberd.Table, 0, len(tableMap))
201+
for _, table := range tableMap {
202+
tables = append(tables, *table)
203+
}
204+
205+
return tables
206+
}
207+
208+
const extractReferencesQuery = `
209+
WITH foreign_keys AS (
210+
SELECT
211+
src_ns.nspname AS source_schema,
212+
src_tbl.relname AS source_table,
213+
src_col.attname AS source_column,
214+
tgt_ns.nspname AS target_schema,
215+
tgt_tbl.relname AS target_table,
216+
tgt_col.attname AS target_column,
217+
ROW_NUMBER() OVER (
218+
PARTITION BY src_ns.nspname, src_tbl.relname, src_col.attname
219+
ORDER BY tgt_ns.nspname, tgt_tbl.relname, tgt_col.attname
220+
) as rn
221+
FROM pg_constraint con
222+
JOIN pg_class src_tbl ON con.conrelid = src_tbl.oid
223+
JOIN pg_namespace src_ns ON src_tbl.relnamespace = src_ns.oid
224+
JOIN pg_class tgt_tbl ON con.confrelid = tgt_tbl.oid
225+
JOIN pg_namespace tgt_ns ON tgt_tbl.relnamespace = tgt_ns.oid
226+
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS src_cols(attnum, ord) ON TRUE
227+
JOIN pg_attribute src_col ON src_col.attrelid = src_tbl.oid AND src_col.attnum = src_cols.attnum
228+
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tgt_cols(attnum, ord) ON src_cols.ord = tgt_cols.ord
229+
JOIN pg_attribute tgt_col ON tgt_col.attrelid = tgt_tbl.oid AND tgt_col.attnum = tgt_cols.attnum
230+
WHERE con.contype = 'f'
231+
)
232+
SELECT
233+
source_schema,
234+
source_table,
235+
source_column,
236+
target_schema,
237+
target_table,
238+
target_column
239+
FROM foreign_keys
240+
WHERE rn = 1
241+
ORDER BY source_schema, source_table, source_column;`
242+
243+
type referenceRow struct {
244+
sourceSchema string
245+
sourceTable string
246+
sourceColumn string
247+
targetSchema string
248+
targetTable string
249+
targetColumn string
250+
}
251+
252+
// extractReferences queries the database for foreign key relationships and converts them to dberd.Reference format.
253+
func (s *Source) extractReferences(ctx context.Context) ([]dberd.Reference, error) {
254+
rows, err := s.db.QueryContext(ctx, extractReferencesQuery)
255+
if err != nil {
256+
return nil, fmt.Errorf("querying references: %w", err)
257+
}
258+
defer rows.Close()
259+
260+
var referenceRows []referenceRow
261+
262+
for rows.Next() {
263+
var r referenceRow
264+
if err := rows.Scan(
265+
&r.sourceSchema,
266+
&r.sourceTable,
267+
&r.sourceColumn,
268+
&r.targetSchema,
269+
&r.targetTable,
270+
&r.targetColumn,
271+
); err != nil {
272+
return nil, fmt.Errorf("scanning references row: %w", err)
273+
}
274+
275+
referenceRows = append(referenceRows, r)
276+
}
277+
278+
if err := rows.Err(); err != nil {
279+
return nil, fmt.Errorf("references rows error: %w", err)
280+
}
281+
282+
return referenceRowsToSchemaReferences(referenceRows), nil
283+
}
284+
285+
// referenceRowsToSchemaReferences converts a slice of referenceRow into a slice of dberd.Reference.
286+
func referenceRowsToSchemaReferences(referenceRows []referenceRow) []dberd.Reference {
287+
references := make([]dberd.Reference, 0, len(referenceRows))
288+
289+
for _, row := range referenceRows {
290+
sourceTable := row.sourceSchema + "." + row.sourceTable
291+
targetTable := row.targetSchema + "." + row.targetTable
292+
293+
references = append(references, dberd.Reference{
294+
Source: dberd.TableColumn{
295+
Table: sourceTable,
296+
Column: row.sourceColumn,
297+
},
298+
Target: dberd.TableColumn{
299+
Table: targetTable,
300+
Column: row.targetColumn,
301+
},
302+
})
303+
}
304+
305+
return references
306+
}

0 commit comments

Comments
 (0)