@prairielearn/postgres-tools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ // @ts-check
2
+ import pg from 'pg';
3
+ import chalk from 'chalk';
4
+ import { loadSqlEquiv, PostgresPool } from '@prairielearn/postgres';
5
+
6
+ const sql = loadSqlEquiv(__filename);
7
+ const pgArray = pg.types.arrayParser;
8
+
9
+ interface ColumnDescription {
10
+ name: string;
11
+ type: string;
12
+ notnull: boolean;
13
+ default: any;
14
+ }
15
+
16
+ interface IndexDescription {
17
+ name: string;
18
+ isprimary: boolean;
19
+ isunique: boolean;
20
+ indexdef: string;
21
+ constraintdef: string;
22
+ contype: string;
23
+ }
24
+
25
+ interface ForeignKeyConstraintDescription {
26
+ name: string;
27
+ def: string;
28
+ }
29
+
30
+ interface ReferenceDescription {
31
+ name: string;
32
+ table: string;
33
+ condef: string;
34
+ }
35
+
36
+ interface CheckConstraintDescription {
37
+ name: string;
38
+ def: string;
39
+ }
40
+
41
+ interface TableDescription {
42
+ columns: ColumnDescription[];
43
+ indexes: IndexDescription[];
44
+ foreignKeyConstraints: ForeignKeyConstraintDescription[];
45
+ references: ReferenceDescription[];
46
+ checkConstraints: CheckConstraintDescription[];
47
+ }
48
+
49
+ export interface DatabaseDescription {
50
+ tables: Record<string, TableDescription>;
51
+ enums: Record<string, string[]>;
52
+ }
53
+
54
+ interface DescribeOptions {
55
+ ignoreTables?: string[];
56
+ ignoreColumns?: string[];
57
+ ignoreEnums?: string[];
58
+ }
59
+
60
+ function parsePostgresArray(arr: string): string[] {
61
+ // @ts-expect-error -- Incorrect type definitions in `pg-types`.
62
+ return pgArray.create(arr, String).parse();
63
+ }
64
+
65
+ async function describeWithPool(
66
+ pool: PostgresPool,
67
+ options: DescribeOptions
68
+ ): Promise<DatabaseDescription> {
69
+ const ignoreTables = options?.ignoreTables || [];
70
+ const ignoreEnums = options?.ignoreEnums || [];
71
+ let ignoreColumns: Record<string, string[]> = {};
72
+
73
+ const output: DatabaseDescription = {
74
+ tables: {},
75
+ enums: {},
76
+ };
77
+
78
+ // Get the names of the tables and filter out any ignored tables
79
+ const tablesRes = await pool.queryAsync(sql.get_tables, []);
80
+ const tables = tablesRes.rows.filter((table) => ignoreTables.indexOf(table.name) === -1);
81
+
82
+ // Transform ignored columns into a map from table names to arrays
83
+ // of column names
84
+ if (options.ignoreColumns && Array.isArray(options.ignoreColumns)) {
85
+ ignoreColumns = options.ignoreColumns
86
+ .filter((ignore) => {
87
+ return /^[^\s.]*\.[^\s.]*$/.test(ignore);
88
+ })
89
+ .reduce((result, value) => {
90
+ const res = /^(([^\s.]*)\.([^\s.]*))$/.exec(value);
91
+ if (!res) {
92
+ throw new Error(`Invalid ignore column: ${value}`);
93
+ }
94
+ const table = res[2];
95
+ const column = res[3];
96
+ (result[table] || (result[table] = [])).push(column);
97
+ return result;
98
+ }, {} as Record<string, string[]>);
99
+ }
100
+
101
+ // Get column info for each table
102
+ for (const table of tables) {
103
+ const columnResults = await pool.queryAsync(sql.get_columns_for_table, {
104
+ oid: table.oid,
105
+ });
106
+
107
+ const columns = columnResults.rows.filter((row) => {
108
+ return (ignoreColumns[table.name] || []).indexOf(row.name) === -1;
109
+ });
110
+
111
+ const indexResults = await pool.queryAsync(sql.get_indexes_for_table, {
112
+ oid: table.oid,
113
+ });
114
+
115
+ const foreignKeyConstraintResults = await pool.queryAsync(
116
+ sql.get_foreign_key_constraints_for_table,
117
+ {
118
+ oid: table.oid,
119
+ }
120
+ );
121
+
122
+ const referenceResults = await pool.queryAsync(sql.get_references_for_table, {
123
+ oid: table.oid,
124
+ });
125
+
126
+ // Filter out references from ignored tables
127
+ const references = referenceResults.rows.filter((row) => {
128
+ return ignoreTables.indexOf(row.table) === -1;
129
+ });
130
+
131
+ const checkConstraintResults = await pool.queryAsync(sql.get_check_constraints_for_table, {
132
+ oid: table.oid,
133
+ });
134
+
135
+ output.tables[table.name] = {
136
+ columns: columns,
137
+ indexes: indexResults.rows,
138
+ foreignKeyConstraints: foreignKeyConstraintResults.rows,
139
+ references: references,
140
+ checkConstraints: checkConstraintResults.rows,
141
+ };
142
+ }
143
+
144
+ // Get all enums
145
+ const enumsRes = await pool.queryAsync(sql.get_enums, []);
146
+
147
+ // Filter ignored enums
148
+ const rows = enumsRes.rows.filter((row) => {
149
+ return ignoreEnums.indexOf(row.name) === -1;
150
+ });
151
+
152
+ rows.forEach((row) => {
153
+ output.enums[row.name] = parsePostgresArray(row.values);
154
+ });
155
+
156
+ return output;
157
+ }
158
+
159
+ /**
160
+ * Will produce a description of a given database's schema. This will include
161
+ * information about tables, enums, constraints, indices, etc.
162
+ */
163
+ export async function describeDatabase(
164
+ databaseName: string,
165
+ options: DescribeOptions = {}
166
+ ): Promise<DatabaseDescription> {
167
+ // Connect to the database.
168
+ const pool = new PostgresPool();
169
+ const pgConfig = {
170
+ user: 'postgres',
171
+ database: databaseName,
172
+ host: 'localhost',
173
+ max: 10,
174
+ idleTimeoutMillis: 30000,
175
+ };
176
+ function idleErrorHandler(err: Error) {
177
+ throw err;
178
+ }
179
+ await pool.initAsync(pgConfig, idleErrorHandler);
180
+
181
+ try {
182
+ return await describeWithPool(pool, options);
183
+ } finally {
184
+ await pool.closeAsync();
185
+ }
186
+ }
187
+
188
+ export function formatDatabaseDescription(
189
+ description: DatabaseDescription,
190
+ options = { coloredOutput: true }
191
+ ): { tables: Record<string, string>; enums: Record<string, string> } {
192
+ const output = {
193
+ tables: {} as Record<string, string>,
194
+ enums: {} as Record<string, string>,
195
+ };
196
+
197
+ Object.keys(description.tables).forEach((tableName) => (output.tables[tableName] = ''));
198
+
199
+ /**
200
+ * Optionally applies the given formatter to the text if colored output is
201
+ * enabled.
202
+ */
203
+ function formatText(text: string, formatter: (s: string) => string): string {
204
+ if (options.coloredOutput) {
205
+ return formatter(text);
206
+ }
207
+ return text;
208
+ }
209
+
210
+ Object.entries(description.tables).forEach(([tableName, table]) => {
211
+ if (table.columns.length > 0) {
212
+ output.tables[tableName] += formatText('columns\n', chalk.underline);
213
+ output.tables[tableName] += table.columns
214
+ .map((row) => {
215
+ let rowText = formatText(` ${row.name}`, chalk.bold);
216
+ rowText += ':' + formatText(` ${row.type}`, chalk.green);
217
+ if (row.notnull) {
218
+ rowText += formatText(' not null', chalk.gray);
219
+ }
220
+ if (row.default) {
221
+ rowText += formatText(` default ${row.default}`, chalk.gray);
222
+ }
223
+ return rowText;
224
+ })
225
+ .join('\n');
226
+ }
227
+
228
+ if (table.indexes.length > 0) {
229
+ if (output.tables[tableName].length !== 0) {
230
+ output.tables[tableName] += '\n\n';
231
+ }
232
+ output.tables[tableName] += formatText('indexes\n', chalk.underline);
233
+ output.tables[tableName] += table.indexes
234
+ .map((row) => {
235
+ const using = row.indexdef.substring(row.indexdef.indexOf('USING '));
236
+ let rowText = formatText(` ${row.name}`, chalk.bold) + ':';
237
+ // Primary indexes are implicitly unique, so we don't need to
238
+ // capture that explicitly.
239
+ if (row.isunique && !row.isprimary) {
240
+ if (!row.constraintdef || row.constraintdef.indexOf('UNIQUE') === -1) {
241
+ // Some unique indexes don't include the UNIQUE constraint
242
+ // as part of the constraint definition, so we need to capture
243
+ // that manually.
244
+ rowText += formatText(` UNIQUE`, chalk.green);
245
+ }
246
+ }
247
+ rowText += row.constraintdef ? formatText(` ${row.constraintdef}`, chalk.green) : '';
248
+ rowText += using ? formatText(` ${using}`, chalk.green) : '';
249
+ return rowText;
250
+ })
251
+ .join('\n');
252
+ }
253
+
254
+ if (table.checkConstraints.length > 0) {
255
+ if (output.tables[tableName].length !== 0) {
256
+ output.tables[tableName] += '\n\n';
257
+ }
258
+ output.tables[tableName] += formatText('check constraints\n', chalk.underline);
259
+ output.tables[tableName] += table.checkConstraints
260
+ .map((row) => {
261
+ // Particularly long constraints are formatted as multiple lines.
262
+ // We'll collapse them into a single line for better appearance in
263
+ // the resulting file.
264
+ //
265
+ // The first replace handles lines that end with a parenthesis: we
266
+ // want to avoid spaces between the parenthesis and the next token.
267
+ //
268
+ // The second replace handles all other lines: we want to collapse
269
+ // all leading whitespace into a single space.
270
+ const def = row.def.replace(/\(\n/g, '(').replace(/\n\s*/g, ' ');
271
+
272
+ let rowText = formatText(` ${row.name}:`, chalk.bold);
273
+ rowText += formatText(` ${def}`, chalk.green);
274
+ return rowText;
275
+ })
276
+ .join('\n');
277
+ }
278
+
279
+ if (table.foreignKeyConstraints.length > 0) {
280
+ if (output.tables[tableName].length !== 0) {
281
+ output.tables[tableName] += '\n\n';
282
+ }
283
+ output.tables[tableName] += formatText('foreign-key constraints\n', chalk.underline);
284
+ output.tables[tableName] += table.foreignKeyConstraints
285
+ .map((row) => {
286
+ let rowText = formatText(` ${row.name}:`, chalk.bold);
287
+ rowText += formatText(` ${row.def}`, chalk.green);
288
+ return rowText;
289
+ })
290
+ .join('\n');
291
+ }
292
+
293
+ if (table.references.length > 0) {
294
+ if (output.tables[tableName].length !== 0) {
295
+ output.tables[tableName] += '\n\n';
296
+ }
297
+ output.tables[tableName] += formatText('referenced by\n', chalk.underline);
298
+ output.tables[tableName] += table.references
299
+ ?.map((row) => {
300
+ let rowText = formatText(` ${row.table}:`, chalk.bold);
301
+ rowText += formatText(` ${row.condef}`, chalk.green);
302
+ return rowText;
303
+ })
304
+ .join('\n');
305
+ }
306
+ });
307
+
308
+ Object.entries(description.enums).forEach(([enumName, enumValues]) => {
309
+ output.enums[enumName] = formatText(enumValues.join(', '), chalk.gray);
310
+ });
311
+
312
+ // We need to tack on a newline to everything.
313
+ Object.entries(output.tables).forEach(([tableName, table]) => {
314
+ output.tables[tableName] = table + '\n';
315
+ });
316
+ Object.entries(output.enums).forEach(([enumName, enumValues]) => {
317
+ output.enums[enumName] = enumValues + '\n';
318
+ });
319
+
320
+ return output;
321
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+ import fs from 'fs-extra';
3
+ import path from 'node:path';
4
+ import chalk from 'chalk';
5
+ import _ from 'lodash';
6
+ import { diffLines } from 'diff';
7
+
8
+ import { describeDatabase, formatDatabaseDescription } from './describe';
9
+
10
+ type DatabaseInfo = { type: 'database'; name: string };
11
+ type DirectoryInfo = { type: 'directory'; path: string };
12
+ type DiffTarget = DatabaseInfo | DirectoryInfo;
13
+ type DiffOptions = { coloredOutput?: boolean };
14
+ type Description = {
15
+ tables: Record<string, string>;
16
+ enums: Record<string, string>;
17
+ };
18
+
19
+ async function diff(db1: DiffTarget, db2: DiffTarget, options: DiffOptions): Promise<string> {
20
+ function formatText(text: string, formatter?: ((s: string) => string) | null): string {
21
+ if (options.coloredOutput && formatter) {
22
+ return formatter(text);
23
+ }
24
+ return text;
25
+ }
26
+
27
+ const db2Name = db2.type === 'database' ? db2.name : db2.path;
28
+ const db2NameBold = formatText(db2Name, chalk.bold);
29
+
30
+ let result = '';
31
+
32
+ const description1 = await loadDescription(db1);
33
+ const description2 = await loadDescription(db2);
34
+
35
+ // Determine if both databases have the same tables
36
+ const tablesMissingFrom1 = _.difference(_.keys(description2.tables), _.keys(description1.tables));
37
+ const tablesMissingFrom2 = _.difference(_.keys(description1.tables), _.keys(description2.tables));
38
+
39
+ if (tablesMissingFrom1.length > 0) {
40
+ result += formatText(`Tables added to ${db2NameBold} (${db2.type})\n`, chalk.underline);
41
+ result += formatText(
42
+ tablesMissingFrom1.map((table) => `+ ${table}`).join('\n') + '\n\n',
43
+ chalk.green
44
+ );
45
+ }
46
+
47
+ if (tablesMissingFrom2.length > 0) {
48
+ result += formatText(`Tables missing from ${db2NameBold} (${db2.type})\n`, chalk.underline);
49
+ result += formatText(
50
+ tablesMissingFrom2.map((table) => `- ${table}`).join('\n') + '\n\n',
51
+ chalk.red
52
+ );
53
+ }
54
+
55
+ // Determine if both databases have the same enums
56
+ const enumsMissingFrom1 = _.difference(_.keys(description2.enums), _.keys(description1.enums));
57
+ const enumsMissingFrom2 = _.difference(_.keys(description1.enums), _.keys(description2.enums));
58
+
59
+ if (enumsMissingFrom1.length > 0) {
60
+ result += formatText(`Enums added to ${db2NameBold} (${db1.type})\n`, chalk.underline);
61
+ result += formatText(
62
+ enumsMissingFrom1.map((enumName) => `+ ${enumName}`).join('\n') + '\n\n',
63
+ chalk.green
64
+ );
65
+ }
66
+
67
+ if (enumsMissingFrom2.length > 0) {
68
+ result += formatText(`Enums missing from ${db2NameBold} (${db2.type})\n`, chalk.underline);
69
+ result += formatText(
70
+ enumsMissingFrom2.map((enumName) => `- ${enumName}`).join('\n') + '\n\n',
71
+ chalk.red
72
+ );
73
+ }
74
+
75
+ // Determine if the columns of any table differ
76
+ const intersection = _.intersection(_.keys(description1.tables), _.keys(description2.tables));
77
+ _.forEach(intersection, (table) => {
78
+ // We normalize each blob to end with a newline to make diffs print cleaner
79
+ const diff = diffLines(
80
+ description1.tables[table].trim() + '\n',
81
+ description2.tables[table].trim() + '\n'
82
+ );
83
+ if (diff.length === 1) return;
84
+
85
+ const boldTable = formatText(table, chalk.bold);
86
+ result += formatText(`Differences in ${boldTable} table\n`, chalk.underline);
87
+
88
+ // Shift around the newlines so that we can cleanly show +/- symbols
89
+ for (let i = 1; i < diff.length; i++) {
90
+ const prev = diff[i - 1].value;
91
+ if (prev[prev.length - 1] === '\n') {
92
+ diff[i - 1].value = prev.slice(0, -1);
93
+ diff[i].value = '\n' + diff[i].value;
94
+ }
95
+ }
96
+
97
+ _.forEach(diff, (part, index) => {
98
+ if (index === 0) {
99
+ part.value = '\n' + part.value;
100
+ }
101
+ const mark = part.added ? '+ ' : part.removed ? '- ' : ' ';
102
+ let change = part.value.split('\n').join(`\n${mark}`);
103
+ if (index === 0) {
104
+ change = change.slice(1, change.length);
105
+ }
106
+ if (part.added || part.removed) {
107
+ result += formatText(change, part.added ? chalk.green : part.removed ? chalk.red : null);
108
+ }
109
+ });
110
+ result += '\n\n';
111
+ });
112
+
113
+ // Determine if the values of any enums differ
114
+ const enumsIntersection = _.intersection(_.keys(description1.enums), _.keys(description2.enums));
115
+ _.forEach(enumsIntersection, (enumName) => {
116
+ // We don't need to do a particularly fancy diff here, since
117
+ // enums are just represented here as strings
118
+ if (description1.enums[enumName].trim() !== description2.enums[enumName].trim()) {
119
+ const boldEnum = formatText(enumName, chalk.bold);
120
+ result += formatText(`Differences in ${boldEnum} enum\n`);
121
+ result += formatText(`- ${description1.enums[enumName].trim()}\n`, chalk.red);
122
+ result += formatText(`+ ${description2.enums[enumName].trim()}\n`, chalk.green);
123
+ result += '\n\n';
124
+ }
125
+ });
126
+
127
+ return result;
128
+ }
129
+
130
+ async function loadDescriptionFromDisk(dirPath: string): Promise<Description> {
131
+ const description: Description = {
132
+ tables: {},
133
+ enums: {},
134
+ };
135
+
136
+ const tables = await fs.readdir(path.join(dirPath, 'tables'));
137
+ for (const table of tables) {
138
+ const data = await fs.readFile(path.join(dirPath, 'tables', table), 'utf8');
139
+ description.tables[table.replace('.pg', '')] = data;
140
+ }
141
+
142
+ const enums = await fs.readdir(path.join(dirPath, 'enums'));
143
+ for (const enumName of enums) {
144
+ const data = await fs.readFile(path.join(dirPath, 'enums', enumName), 'utf8');
145
+ description.enums[enumName.replace('.pg', '')] = data;
146
+ }
147
+
148
+ return description;
149
+ }
150
+
151
+ async function loadDescriptionFromDatabase(name: string) {
152
+ const description = await describeDatabase(name);
153
+ return formatDatabaseDescription(description, { coloredOutput: false });
154
+ }
155
+
156
+ async function loadDescription(db: DiffTarget): Promise<Description> {
157
+ if (db.type === 'database') {
158
+ return loadDescriptionFromDatabase(db.name);
159
+ } else if (db.type === 'directory') {
160
+ return loadDescriptionFromDisk(db.path);
161
+ } else {
162
+ throw new Error('Invalid database type');
163
+ }
164
+ }
165
+
166
+ export async function diffDatabases(database1: string, database2: string, options: DiffOptions) {
167
+ return diff(
168
+ {
169
+ type: 'database',
170
+ name: database1,
171
+ },
172
+ {
173
+ type: 'database',
174
+ name: database2,
175
+ },
176
+ options
177
+ );
178
+ }
179
+
180
+ export async function diffDatabaseAndDirectory(
181
+ database: string,
182
+ directory: string,
183
+ options: DiffOptions
184
+ ) {
185
+ return diff(
186
+ {
187
+ type: 'database',
188
+ name: database,
189
+ },
190
+ {
191
+ type: 'directory',
192
+ path: directory,
193
+ },
194
+ options
195
+ );
196
+ }
197
+
198
+ export async function diffDirectoryAndDatabase(
199
+ directory: string,
200
+ database: string,
201
+ options: DiffOptions
202
+ ) {
203
+ return diff(
204
+ {
205
+ type: 'directory',
206
+ path: directory,
207
+ },
208
+ {
209
+ type: 'database',
210
+ name: database,
211
+ },
212
+ options
213
+ );
214
+ }
215
+
216
+ export async function diffDirectories(
217
+ directory1: string,
218
+ directory2: string,
219
+ options: DiffOptions
220
+ ) {
221
+ return diff(
222
+ {
223
+ type: 'directory',
224
+ path: directory1,
225
+ },
226
+ {
227
+ type: 'directory',
228
+ path: directory2,
229
+ },
230
+ options
231
+ );
232
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { describeDatabase, formatDatabaseDescription } from './describe';
2
+ export {
3
+ diffDatabases,
4
+ diffDirectories,
5
+ diffDatabaseAndDirectory,
6
+ diffDirectoryAndDatabase,
7
+ } from './diff';
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@prairielearn/tsconfig",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["mocha", "node"],
7
+ }
8
+ }