Skip to content

Commit 969b62b

Browse files
authored
Support exporting tables with composite foreign keys #12 (#14)
* Support exporting tables with composite foreign keys #12 * Fix flaky tests
1 parent 8909d9e commit 969b62b

File tree

7 files changed

+168
-59
lines changed

7 files changed

+168
-59
lines changed

db.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func RunRipoff(ctx context.Context, tx pgx.Tx, totalRipoff RipoffFile) error {
4343
}
4444

4545
const primaryKeysQuery = `
46-
SELECT STRING_AGG(c.column_name, '|'), tc.table_name
46+
SELECT STRING_AGG(c.column_name, '|' order by c.column_name), tc.table_name
4747
FROM information_schema.table_constraints tc
4848
JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_name)
4949
JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema
@@ -77,7 +77,7 @@ func getPrimaryKeys(ctx context.Context, tx pgx.Tx) (PrimaryKeysResult, error) {
7777
}
7878

7979
const enumValuesQuery = `
80-
SELECT STRING_AGG(e.enumlabel, '|'), t.typname
80+
SELECT STRING_AGG(e.enumlabel, '|' order by e.enumlabel), t.typname
8181
FROM pg_type t
8282
JOIN pg_enum e ON t.oid = e.enumtypid
8383
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
@@ -139,7 +139,7 @@ func prepareValue(rawValue string) (string, error) {
139139
case "literal":
140140
return value, nil
141141
case "naturalDate":
142-
parsed, err := naturaldate.Parse(value, time.Now())
142+
parsed, err := naturaldate.Parse(value, time.Now().UTC())
143143
return parsed.Format(time.RFC3339), err
144144
}
145145

@@ -321,7 +321,8 @@ left join information_schema.key_column_usage rel
321321
on rco.unique_constraint_name = rel.constraint_name
322322
and rco.unique_constraint_schema = rel.constraint_schema
323323
and rel.ordinal_position = kcu.position_in_unique_constraint
324-
where col.table_schema = 'public';
324+
where col.table_schema = 'public'
325+
order by col.table_name, col.column_name;
325326
`
326327

327328
type ForeignKey struct {

export.go

+58-50
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import (
1111
)
1212

1313
type RowMissingDependency struct {
14-
Row Row
15-
ToTable string
16-
ToColumn string
17-
UniqueValue string
14+
Row Row
15+
ConstraintMapKey [3]string
1816
}
1917

2018
// Exports all rows in the database to a ripoff file.
@@ -35,25 +33,12 @@ func ExportToRipoff(ctx context.Context, tx pgx.Tx) (RipoffFile, error) {
3533
}
3634
// A map from [table,column] -> ForeignKey for single column foreign keys.
3735
singleColumnFkeyMap := map[[2]string]*ForeignKey{}
38-
// A map from [table,column] -> a map of column values to row keys (ex: users:literal(1)) of the given table.
39-
uniqueConstraintMap := map[[2]string]map[string]string{}
40-
// A map from table to a list of columns that need mapped in uniqueConstraintMap.
41-
hasUniqueConstraintMap := map[string][]string{}
36+
// A map from [table,constraintName,values] -> rowKey.
37+
constraintMap := map[[3]string]string{}
4238
for table, tableInfo := range foreignKeyResult {
4339
for _, foreignKey := range tableInfo.ForeignKeys {
44-
// We could possibly maintain a uniqueConstraintMap map for these as well, but tabling for now.
45-
if len(foreignKey.ColumnConditions) != 1 {
46-
continue
47-
}
48-
singleColumnFkeyMap[[2]string{table, foreignKey.ColumnConditions[0][0]}] = foreignKey
49-
// This is a foreign key to a unique index, not a primary key.
50-
if len(primaryKeyResult[foreignKey.ToTable]) == 1 && primaryKeyResult[foreignKey.ToTable][0] != foreignKey.ColumnConditions[0][1] {
51-
_, ok := hasUniqueConstraintMap[foreignKey.ToTable]
52-
if !ok {
53-
hasUniqueConstraintMap[foreignKey.ToTable] = []string{}
54-
}
55-
uniqueConstraintMap[[2]string{foreignKey.ToTable, foreignKey.ColumnConditions[0][1]}] = map[string]string{}
56-
hasUniqueConstraintMap[foreignKey.ToTable] = append(hasUniqueConstraintMap[foreignKey.ToTable], foreignKey.ColumnConditions[0][1])
40+
if len(foreignKey.ColumnConditions) == 1 {
41+
singleColumnFkeyMap[[2]string{table, foreignKey.ColumnConditions[0][0]}] = foreignKey
5742
}
5843
}
5944
}
@@ -89,6 +74,8 @@ func ExportToRipoff(ctx context.Context, tx pgx.Tx) (RipoffFile, error) {
8974
}
9075
}
9176
ripoffRow := Row{}
77+
// A map of fieldName -> tableName to convert values to literal:(...)
78+
literalFields := map[string]string{}
9279
ids := []string{}
9380
for i, field := range fields {
9481
// Null columns are still exported since we don't know if there is a default or not (at least not at time of writing).
@@ -114,51 +101,72 @@ func ExportToRipoff(ctx context.Context, tx pgx.Tx) (RipoffFile, error) {
114101
}
115102
continue
116103
}
117-
// If this is a foreign key, should ensure it uses the table:valueFunc() format.
118-
if isFkey && columnVal != "" {
119-
// Does the referenced table have more than one primary key, or does the constraint not point to a primary key?
120-
// Then is a foreign key to a non-primary key, we need to fill this info in later.
121-
if len(primaryKeyResult[foreignKey.ToTable]) != 1 || primaryKeyResult[foreignKey.ToTable][0] != foreignKey.ColumnConditions[0][1] {
122-
missingDependencies = append(missingDependencies, RowMissingDependency{
123-
Row: ripoffRow,
124-
UniqueValue: columnVal,
125-
ToTable: foreignKey.ToTable,
126-
ToColumn: foreignKey.ColumnConditions[0][1],
127-
})
128-
} else {
129-
ripoffRow[field.Name] = fmt.Sprintf("%s:literal(%s)", foreignKey.ToTable, columnVal)
130-
continue
131-
}
104+
// If this is a foreign key to a single-column primary key, we can use literal() instead of ~dependencies.
105+
if isFkey && columnVal != "" && len(primaryKeyResult[foreignKey.ToTable]) == 1 && primaryKeyResult[foreignKey.ToTable][0] == foreignKey.ColumnConditions[0][1] {
106+
literalFields[field.Name] = foreignKey.ToTable
132107
}
133108
// Normal column.
134109
ripoffRow[field.Name] = columnVal
135110
}
136111
rowKey := fmt.Sprintf("%s:literal(%s)", table, strings.Join(ids, "."))
137-
// For foreign keys to non-unique fields, we need to maintain our own map of unique values to rowKeys.
138-
columnsThatNeepMapped, needsMapped := hasUniqueConstraintMap[table]
139-
if needsMapped {
140-
for i, field := range fields {
141-
if columns[i] == nil {
112+
// Hash values of this row for dependency lookups in the future.
113+
for _, fkeys := range foreignKeyResult {
114+
for constraintName, fkey := range fkeys.ForeignKeys {
115+
if fkey.ToTable != table {
142116
continue
143117
}
144-
columnVal := *columns[i]
145-
if slices.Contains(columnsThatNeepMapped, field.Name) {
146-
uniqueConstraintMap[[2]string{table, field.Name}][columnVal] = rowKey
118+
values := []string{}
119+
abort := false
120+
for _, conditions := range fkey.ColumnConditions {
121+
toColumnValue, hasToColumn := ripoffRow[conditions[1]]
122+
if hasToColumn && toColumnValue.(string) != "" {
123+
values = append(values, toColumnValue.(string))
124+
} else {
125+
abort = true
126+
break
127+
}
128+
}
129+
if abort {
130+
continue
147131
}
132+
constraintMap[[3]string{table, constraintName, strings.Join(values, ",")}] = rowKey
148133
}
149134
}
135+
// Now register missing dependencies for all our foreign keys.
136+
for constraintName, fkey := range foreignKeyResult[table].ForeignKeys {
137+
values := []string{}
138+
allLiteral := true
139+
for _, condition := range fkey.ColumnConditions {
140+
fieldValue, hasField := ripoffRow[condition[0]]
141+
fieldValueStr, isString := fieldValue.(string)
142+
if hasField && isString && fieldValue != "" {
143+
_, isLiteral := literalFields[condition[0]]
144+
if !isLiteral {
145+
allLiteral = false
146+
}
147+
values = append(values, fieldValueStr)
148+
}
149+
}
150+
// We have enough values to satisfy the column conditions.
151+
if !allLiteral && len(values) == len(fkey.ColumnConditions) {
152+
missingDependencies = append(missingDependencies, RowMissingDependency{
153+
Row: ripoffRow,
154+
ConstraintMapKey: [3]string{fkey.ToTable, constraintName, strings.Join(values, ",")},
155+
})
156+
}
157+
}
158+
// Finally convert some fields to use literal() for UX reasons.
159+
for fieldName, toTable := range literalFields {
160+
ripoffRow[fieldName] = fmt.Sprintf("%s:literal(%s)", toTable, ripoffRow[fieldName])
161+
}
150162
ripoffFile.Rows[rowKey] = ripoffRow
151163
}
152164
}
153165
// Resolve missing dependencies now that all rows are in memory.
154166
for _, missingDependency := range missingDependencies {
155-
valueMap, ok := uniqueConstraintMap[[2]string{missingDependency.ToTable, missingDependency.ToColumn}]
156-
if !ok {
157-
return ripoffFile, fmt.Errorf("row has dependency on column %s.%s which is not mapped", missingDependency.ToTable, missingDependency.ToColumn)
158-
}
159-
rowKey, ok := valueMap[missingDependency.UniqueValue]
167+
rowKey, ok := constraintMap[missingDependency.ConstraintMapKey]
160168
if !ok {
161-
return ripoffFile, fmt.Errorf("row has dependency on column %s.%s which does not contain unqiue value %s", missingDependency.ToTable, missingDependency.ToColumn, missingDependency.UniqueValue)
169+
return ripoffFile, fmt.Errorf("row has missing dependency on constraint map key %s", missingDependency.ConstraintMapKey)
162170
}
163171
dependencies, ok := missingDependency.Row["~dependencies"].([]string)
164172
if !ok {

export_test.go

+17
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/jackc/pgx/v5"
1313
"github.com/lib/pq"
1414
"github.com/stretchr/testify/require"
15+
"gopkg.in/yaml.v3"
1516
)
1617

1718
func runExportTestData(t *testing.T, ctx context.Context, tx pgx.Tx, testDir string) {
@@ -20,9 +21,25 @@ func runExportTestData(t *testing.T, ctx context.Context, tx pgx.Tx, testDir str
2021
require.NoError(t, err)
2122
_, err = tx.Exec(ctx, string(setupFile))
2223
require.NoError(t, err)
24+
2325
// Generate new ripoff file.
2426
ripoffFile, err := ExportToRipoff(ctx, tx)
2527
require.NoError(t, err)
28+
29+
// Ensure ripoff file matches expected output.
30+
// The marshal/unmashal dance here lets us ensure everything is an interface{}
31+
newRipoffFile := &RipoffFile{}
32+
newRipoffBytes, err := yaml.Marshal(ripoffFile)
33+
require.NoError(t, err)
34+
err = yaml.Unmarshal(newRipoffBytes, newRipoffFile)
35+
require.NoError(t, err)
36+
expectedRipoffYaml, err := os.ReadFile(path.Join(testDir, "ripoff.yml"))
37+
require.NoError(t, err)
38+
expectedRipoffFile := &RipoffFile{}
39+
err = yaml.Unmarshal(expectedRipoffYaml, expectedRipoffFile)
40+
require.NoError(t, err)
41+
require.Equal(t, expectedRipoffFile, newRipoffFile)
42+
2643
// Wipe database.
2744
truncateFile, err := os.ReadFile(path.Join(testDir, "truncate.sql"))
2845
require.NoError(t, err)

testdata/export/basic/ripoff.yml

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
rows:
2+
avatar_modifiers:literal(0cf7650c-a1ed-11ef-b864-0242ac120002):
3+
~dependencies:
4+
- avatars:literal(0cf7650c-a1ed-11ef-b864-0242ac120002)
5+
grayscale: "true"
6+
avatar_modifiers:literal(09af5166-a1ed-11ef-b864-0242ac120002):
7+
~dependencies:
8+
- avatars:literal(09af5166-a1ed-11ef-b864-0242ac120002)
9+
grayscale: "false"
10+
avatar_modifiers:literal(184e5e10-a1ed-11ef-b864-0242ac120002):
11+
~dependencies:
12+
- avatars:literal(184e5e10-a1ed-11ef-b864-0242ac120002)
13+
grayscale: "false"
14+
avatars:literal(0cf7650c-a1ed-11ef-b864-0242ac120002):
15+
url: second.png
16+
avatars:literal(09af5166-a1ed-11ef-b864-0242ac120002):
17+
url: first.png
18+
avatars:literal(184e5e10-a1ed-11ef-b864-0242ac120002):
19+
url: third.png
20+
employees:literal(1):
21+
~dependencies:
22+
- roles:literal(d1e36a3c-32ca-4b26-9358-ab117b685aaf)
23+
role: Boss
24+
employees:literal(2):
25+
~dependencies:
26+
- roles:literal(17b0b806-a907-4097-a86e-d5a2e44a55b0)
27+
role: Mini Boss
28+
employees:literal(3):
29+
~dependencies:
30+
- roles:literal(65906a7e-877c-4e39-acc2-f2accd1495f1)
31+
role: Minion
32+
multi_column_fkey:literal(737ba6d2-ed63-11ef-9cd2-0242ac120002):
33+
~dependencies:
34+
- multi_column_pkey:literal(0a794e82-ed63-11ef-9cd2-0242ac120002.6d5c2f60-ed63-11ef-9cd2-0242ac120002)
35+
id1_fkey: 0a794e82-ed63-11ef-9cd2-0242ac120002
36+
id2_fkey: 6d5c2f60-ed63-11ef-9cd2-0242ac120002
37+
multi_column_pkey:literal(0a794e82-ed63-11ef-9cd2-0242ac120002.6d5c2f60-ed63-11ef-9cd2-0242ac120002):
38+
id1: 0a794e82-ed63-11ef-9cd2-0242ac120002
39+
id2: 6d5c2f60-ed63-11ef-9cd2-0242ac120002
40+
roles:literal(17b0b806-a907-4097-a86e-d5a2e44a55b0):
41+
name: Mini Boss
42+
roles:literal(65906a7e-877c-4e39-acc2-f2accd1495f1):
43+
name: Minion
44+
roles:literal(d1e36a3c-32ca-4b26-9358-ab117b685aaf):
45+
name: Boss
46+
users:literal(448e6222-a1ed-11ef-b864-0242ac120002):
47+
avatar_id: avatars:literal(09af5166-a1ed-11ef-b864-0242ac120002)
48+
49+
employee_id: employees:literal(1)
50+
users:literal(459a966e-a1f1-11ef-b864-0242ac120002):
51+
avatar_id: avatars:literal(0cf7650c-a1ed-11ef-b864-0242ac120002)
52+
53+
employee_id: employees:literal(2)
54+
users:literal(4848cf02-a1f1-11ef-b864-0242ac120002):
55+
avatar_id: avatars:literal(184e5e10-a1ed-11ef-b864-0242ac120002)
56+
57+
employee_id: employees:literal(3)

testdata/export/basic/setup.sql

+29-3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ CREATE TABLE users (
3232
employee_id BIGSERIAL NOT NULL REFERENCES employees
3333
);
3434

35+
CREATE TABLE multi_column_pkey (
36+
id1 UUID NOT NULL,
37+
id2 UUID NOT NULL,
38+
PRIMARY KEY (id1, id2)
39+
);
40+
41+
CREATE TABLE multi_column_fkey (
42+
id UUID NOT NULL PRIMARY KEY,
43+
id1_fkey UUID NOT NULL,
44+
id2_fkey UUID NOT NULL
45+
);
46+
47+
ALTER TABLE multi_column_fkey
48+
ADD CONSTRAINT multi_column_fkey_multi_column_pkey
49+
FOREIGN KEY (id1_fkey, id2_fkey) REFERENCES multi_column_pkey (id1, id2);
50+
3551
INSERT INTO avatars
3652
(id, url)
3753
VALUES
@@ -49,9 +65,9 @@ INSERT INTO avatar_modifiers
4965
INSERT INTO roles
5066
(id, name)
5167
VALUES
52-
(gen_random_uuid(), 'Boss'),
53-
(gen_random_uuid(), 'Mini Boss'),
54-
(gen_random_uuid(), 'Minion');
68+
('d1e36a3c-32ca-4b26-9358-ab117b685aaf', 'Boss'),
69+
('17b0b806-a907-4097-a86e-d5a2e44a55b0', 'Mini Boss'),
70+
('65906a7e-877c-4e39-acc2-f2accd1495f1', 'Minion');
5571

5672
INSERT INTO employees
5773
(id, role)
@@ -66,3 +82,13 @@ INSERT INTO users
6682
('448e6222-a1ed-11ef-b864-0242ac120002', '09af5166-a1ed-11ef-b864-0242ac120002', '[email protected]', 1),
6783
('459a966e-a1f1-11ef-b864-0242ac120002', '0cf7650c-a1ed-11ef-b864-0242ac120002', '[email protected]', 2),
6884
('4848cf02-a1f1-11ef-b864-0242ac120002', '184e5e10-a1ed-11ef-b864-0242ac120002', '[email protected]', 3);
85+
86+
INSERT INTO multi_column_pkey
87+
(id1, id2)
88+
VALUES
89+
('0a794e82-ed63-11ef-9cd2-0242ac120002', '6d5c2f60-ed63-11ef-9cd2-0242ac120002');
90+
91+
INSERT INTO multi_column_fkey
92+
(id, id1_fkey, id2_fkey)
93+
VALUES
94+
('737ba6d2-ed63-11ef-9cd2-0242ac120002', '0a794e82-ed63-11ef-9cd2-0242ac120002', '6d5c2f60-ed63-11ef-9cd2-0242ac120002');

testdata/export/basic/truncate.sql

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
TRUNCATE TABLE users, avatar_modifiers, avatars, employees CASCADE;
1+
TRUNCATE TABLE users, avatar_modifiers, avatars, employees, multi_column_pkey, multi_column_fkey CASCADE;

testdata/import/faker/validate.sql

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ WITH test AS (
44
AND id = '6b30cfb0-a35b-4584-a035-1334515f846b'
55
AND date_trunc('day', last_logged_in_at) = date_trunc('day', now() - interval '10 days')
66
)
7-
SELECT (select count from test),'email: ' || users.email || ' id: ' || users.id || ' last_logged_in_at: ' || users.last_logged_in_at
7+
SELECT (select count from test),'email: ' || users.email || ' id: ' || users.id || ' last_logged_in_at: ' || date_trunc('day', last_logged_in_at) || ' 10_days_ago: ' || date_trunc('day', now() - interval '10 days')
88
FROM users;

0 commit comments

Comments
 (0)