diff --git a/.gitignore b/.gitignore index 8d050113..250c4f26 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ typings/ # Optional npm cache directory .npm +# npm config +.npmrc + # Optional eslint cache .eslintcache diff --git a/src/lib/PostgresMetaColumns.ts b/src/lib/PostgresMetaColumns.ts index 07224c9c..250d7904 100644 --- a/src/lib/PostgresMetaColumns.ts +++ b/src/lib/PostgresMetaColumns.ts @@ -115,89 +115,101 @@ WHERE } } - async create({ - table_id, - name, - type, - default_value, - default_value_format = 'literal', - is_identity = false, - identity_generation = 'BY DEFAULT', - // Can't pick a value as default since regular columns are nullable by default but PK columns aren't - is_nullable, - is_primary_key = false, - is_unique = false, - comment, - check, - }: { - table_id: number - name: string - type: string - default_value?: any - default_value_format?: 'expression' | 'literal' - is_identity?: boolean - identity_generation?: 'BY DEFAULT' | 'ALWAYS' - is_nullable?: boolean - is_primary_key?: boolean - is_unique?: boolean - comment?: string - check?: string - }): Promise> { + async create( + columns: { + table_id: number + name: string + type: string + default_value?: any + default_value_format?: 'expression' | 'literal' + is_identity?: boolean + identity_generation?: 'BY DEFAULT' | 'ALWAYS' + is_nullable?: boolean + is_primary_key?: boolean + is_unique?: boolean + comment?: string + check?: string + }[] + ): Promise> { + const { table_id } = columns[0] + const { data, error } = await this.metaTables.retrieve({ id: table_id }) if (error) { return { data: null, error } } const { name: table, schema } = data! + let sql = `BEGIN;` + for (const column of columns) { + const { + name, + type, + default_value, + default_value_format = 'literal', + is_identity = false, + identity_generation = 'BY DEFAULT', + // Can't pick a value as default since regular columns are nullable by default but PK columns aren't + is_nullable, + is_primary_key = false, + is_unique = false, + comment, + check, + } = column + sql += ` + ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)}` - let defaultValueClause = '' - if (is_identity) { - if (default_value !== undefined) { - return { - data: null, - error: { message: 'Columns cannot both be identity and have a default value' }, + let defaultValueClause = '' + if (is_identity) { + if (default_value !== undefined) { + return { + data: null, + error: { message: 'Columns cannot both be identity and have a default value' }, + } } - } - defaultValueClause = `GENERATED ${identity_generation} AS IDENTITY` - } else { - if (default_value === undefined) { - // skip - } else if (default_value_format === 'expression') { - defaultValueClause = `DEFAULT ${default_value}` + defaultValueClause = `GENERATED ${identity_generation} AS IDENTITY` } else { - defaultValueClause = `DEFAULT ${literal(default_value)}` + if (default_value === undefined) { + // skip + } else if (default_value_format === 'expression') { + defaultValueClause = `DEFAULT ${default_value}` + } else { + defaultValueClause = `DEFAULT ${literal(default_value)}` + } } - } - let isNullableClause = '' - if (is_nullable !== undefined) { - isNullableClause = is_nullable ? 'NULL' : 'NOT NULL' + let isNullableClause = '' + if (is_nullable !== undefined) { + isNullableClause = is_nullable ? 'NULL' : 'NOT NULL' + } + const isPrimaryKeyClause = is_primary_key ? 'PRIMARY KEY' : '' + const isUniqueClause = is_unique ? 'UNIQUE' : '' + const checkSql = check === undefined ? '' : `CHECK (${check})` + const commentSql = + comment === undefined + ? '' + : `COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal( + comment + )}` + + sql += ` + ${defaultValueClause} + ${isNullableClause} + ${isPrimaryKeyClause} + ${isUniqueClause} + ${checkSql}; + ${commentSql};` } - const isPrimaryKeyClause = is_primary_key ? 'PRIMARY KEY' : '' - const isUniqueClause = is_unique ? 'UNIQUE' : '' - const checkSql = check === undefined ? '' : `CHECK (${check})` - const commentSql = - comment === undefined - ? '' - : `COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal(comment)}` - const sql = ` -BEGIN; - ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)} - ${defaultValueClause} - ${isNullableClause} - ${isPrimaryKeyClause} - ${isUniqueClause} - ${checkSql}; - ${commentSql}; -COMMIT;` + sql += `COMMIT;` { const { error } = await this.query(sql) if (error) { return { data: null, error } } } - return await this.retrieve({ name, table, schema }) + const res = await this.list({ tableId: table_id, includedSchemas: [schema] }) + res.data = res.data?.filter((d) => columns.find((c) => d.name === c.name)) as PostgresColumn[] + return res } async update( diff --git a/src/server/routes/columns.ts b/src/server/routes/columns.ts index 54a7ba2a..d640b298 100644 --- a/src/server/routes/columns.ts +++ b/src/server/routes/columns.ts @@ -140,9 +140,9 @@ const route: FastifyPluginAsyncTypebox = async (fastify) => { headers: Type.Object({ pg: Type.String(), }), - body: postgresColumnCreateSchema, + body: Type.Union([postgresColumnCreateSchema, Type.Array(postgresColumnCreateSchema)]), response: { - 200: postgresColumnSchema, + 200: Type.Union([postgresColumnSchema, Type.Array(postgresColumnSchema)]), 400: Type.Object({ error: Type.String(), }), @@ -153,7 +153,8 @@ const route: FastifyPluginAsyncTypebox = async (fastify) => { const connectionString = request.headers.pg const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) - const { data, error } = await pgMeta.columns.create(request.body) + const colMutations = Array.isArray(request.body) ? request.body : [request.body] + const { data, error } = await pgMeta.columns.create(colMutations) await pgMeta.end() if (error) { request.log.error({ error, request: extractRequestForLogging(request) }) @@ -162,7 +163,7 @@ const route: FastifyPluginAsyncTypebox = async (fastify) => { return { error: error.message } } - return data + return Array.isArray(request.body) ? data : data[0] } ) diff --git a/test/lib/columns.ts b/test/lib/columns.ts index 59fff2bd..32d5c3e6 100644 --- a/test/lib/columns.ts +++ b/test/lib/columns.ts @@ -141,42 +141,46 @@ test('list columns with excluded schemas and include System Schemas', async () = test('retrieve, create, update, delete', async () => { const { data: testTable }: any = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'int2', - default_value: 42, - comment: 'foo', - }) - expect(res).toMatchInlineSnapshot( - { data: { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) } }, + const createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'int2', + default_value: 42, + comment: 'foo', + }, + ]) + expect(createRes).toMatchInlineSnapshot( + { data: [{ id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) }] }, ` { - "data": { - "check": null, - "comment": "foo", - "data_type": "smallint", - "default_value": "'42'::smallint", - "enums": [], - "format": "int2", - "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, - "identity_generation": null, - "is_generated": false, - "is_identity": false, - "is_nullable": true, - "is_unique": false, - "is_updatable": true, - "name": "c", - "ordinal_position": 1, - "schema": "public", - "table": "t", - "table_id": Any, - }, + "data": [ + { + "check": null, + "comment": "foo", + "data_type": "smallint", + "default_value": "'42'::smallint", + "enums": [], + "format": "int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], "error": null, } ` ) - res = await pgMeta.columns.retrieve({ id: res.data!.id }) + let res = await pgMeta.columns.retrieve({ id: createRes.data![0].id }) expect(res).toMatchInlineSnapshot( { data: { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) }, @@ -288,6 +292,187 @@ test('retrieve, create, update, delete', async () => { await pgMeta.tables.remove(testTable!.id) }) +test('create multiple columns at once', async () => { + const { data: testTable }: any = await pgMeta.tables.create({ name: 't' }) + + const createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'a', + type: 'int2', + default_value: 42, + comment: 'foo', + }, + { + table_id: testTable!.id, + name: 'b', + type: 'int2[]', + }, + { + table_id: testTable!.id, + name: 'c', + type: 'timestamptz', + default_value: 'NOW()', + default_value_format: 'expression', + }, + ]) + expect(createRes.error).toBeNull() + expect(createRes).toMatchInlineSnapshot( + { + data: [ + { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) }, + { id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) }, + { id: expect.stringMatching(/^\d+\.3$/), table_id: expect.any(Number) }, + ], + }, + ` + { + "data": [ + { + "check": null, + "comment": "foo", + "data_type": "smallint", + "default_value": "'42'::smallint", + "enums": [], + "format": "int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "a", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + { + "check": null, + "comment": null, + "data_type": "ARRAY", + "default_value": null, + "enums": [], + "format": "_int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "b", + "ordinal_position": 2, + "schema": "public", + "table": "t", + "table_id": Any, + }, + { + "check": null, + "comment": null, + "data_type": "timestamp with time zone", + "default_value": "now()", + "enums": [], + "format": "timestamptz", + "id": StringMatching /\\^\\\\d\\+\\\\\\.3\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 3, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], + "error": null, + } + ` + ) + let res = await pgMeta.columns.list({ tableId: testTable.id }) + expect(res).toMatchInlineSnapshot( + { + data: [ + { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) }, + { id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) }, + { id: expect.stringMatching(/^\d+\.3$/), table_id: expect.any(Number) }, + ], + }, + ` + { + "data": [ + { + "check": null, + "comment": "foo", + "data_type": "smallint", + "default_value": "'42'::smallint", + "enums": [], + "format": "int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "a", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + { + "check": null, + "comment": null, + "data_type": "ARRAY", + "default_value": null, + "enums": [], + "format": "_int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "b", + "ordinal_position": 2, + "schema": "public", + "table": "t", + "table_id": Any, + }, + { + "check": null, + "comment": null, + "data_type": "timestamp with time zone", + "default_value": "now()", + "enums": [], + "format": "timestamptz", + "id": StringMatching /\\^\\\\d\\+\\\\\\.3\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 3, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], + "error": null, + } + ` + ) + await pgMeta.tables.remove(testTable!.id) +}) + test('enum column with quoted name', async () => { await pgMeta.query('CREATE TYPE "T" AS ENUM (\'v\'); CREATE TABLE t ( c "T" );') @@ -329,12 +514,14 @@ test('enum column with quoted name', async () => { test('primary key column', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'int2', - is_primary_key: true, - }) + await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'int2', + is_primary_key: true, + }, + ]) const res = await pgMeta.query(` SELECT a.attname FROM pg_index i @@ -360,12 +547,14 @@ AND i.indisprimary; test('unique column', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'int2', - is_unique: true, - }) + await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'int2', + is_unique: true, + }, + ]) const res = await pgMeta.query(` SELECT a.attname FROM pg_index i @@ -392,40 +581,46 @@ AND i.indisunique; test('array column', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - const res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'int2[]', - }) + const res = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'int2[]', + }, + ]) expect(res).toMatchInlineSnapshot( { - data: { - id: expect.stringMatching(/^\d+\.1$/), - table_id: expect.any(Number), - }, + data: [ + { + id: expect.stringMatching(/^\d+\.1$/), + table_id: expect.any(Number), + }, + ], }, ` { - "data": { - "check": null, - "comment": null, - "data_type": "ARRAY", - "default_value": null, - "enums": [], - "format": "_int2", - "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, - "identity_generation": null, - "is_generated": false, - "is_identity": false, - "is_nullable": true, - "is_unique": false, - "is_updatable": true, - "name": "c", - "ordinal_position": 1, - "schema": "public", - "table": "t", - "table_id": Any, - }, + "data": [ + { + "check": null, + "comment": null, + "data_type": "ARRAY", + "default_value": null, + "enums": [], + "format": "_int2", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], "error": null, } ` @@ -437,42 +632,48 @@ test('array column', async () => { test('column with default value', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - const res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'timestamptz', - default_value: 'NOW()', - default_value_format: 'expression', - }) + const res = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'timestamptz', + default_value: 'NOW()', + default_value_format: 'expression', + }, + ]) expect(res).toMatchInlineSnapshot( { - data: { - id: expect.stringMatching(/^\d+\.1$/), - table_id: expect.any(Number), - }, + data: [ + { + id: expect.stringMatching(/^\d+\.1$/), + table_id: expect.any(Number), + }, + ], }, ` { - "data": { - "check": null, - "comment": null, - "data_type": "timestamp with time zone", - "default_value": "now()", - "enums": [], - "format": "timestamptz", - "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, - "identity_generation": null, - "is_generated": false, - "is_identity": false, - "is_nullable": true, - "is_unique": false, - "is_updatable": true, - "name": "c", - "ordinal_position": 1, - "schema": "public", - "table": "t", - "table_id": Any, - }, + "data": [ + { + "check": null, + "comment": null, + "data_type": "timestamp with time zone", + "default_value": "now()", + "enums": [], + "format": "timestamptz", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], "error": null, } ` @@ -484,12 +685,14 @@ test('column with default value', async () => { test('column with constraint', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'text', - check: "description <> ''", - }) + await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'text', + check: "description <> ''", + }, + ]) const res = await pgMeta.query(` SELECT pg_get_constraintdef(( SELECT c.oid @@ -514,12 +717,14 @@ SELECT pg_get_constraintdef(( test('update with name unchanged', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'int2', - }) - res = await pgMeta.columns.update(res.data!.id, { + let createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'int2', + }, + ]) + let res = await pgMeta.columns.update(createRes.data![0].id, { name: 'c', }) expect(res).toMatchInlineSnapshot( @@ -562,12 +767,14 @@ test('update with name unchanged', async () => { test('update with array types', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'text', - }) - res = await pgMeta.columns.update(res.data!.id, { + let createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'text', + }, + ]) + let res = await pgMeta.columns.update(createRes.data![0].id, { type: 'text[]', }) expect(res).toMatchInlineSnapshot( @@ -610,12 +817,14 @@ test('update with array types', async () => { test('update with incompatible types', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'text', - }) - res = await pgMeta.columns.update(res.data!.id, { + let createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'text', + }, + ]) + let res = await pgMeta.columns.update(createRes.data![0].id, { type: 'int4', }) expect(res).toMatchInlineSnapshot( @@ -658,13 +867,15 @@ test('update with incompatible types', async () => { test('update is_unique', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'text', - is_unique: false, - }) - res = await pgMeta.columns.update(res.data!.id, { is_unique: true }) + let createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'text', + is_unique: false, + }, + ]) + let res = await pgMeta.columns.update(createRes.data![0].id, { is_unique: true }) expect(res).toMatchInlineSnapshot( { data: { @@ -741,13 +952,15 @@ test('alter column to type with uppercase', async () => { const { data: testTable } = await pgMeta.tables.create({ name: 't' }) await pgMeta.query('CREATE TYPE "T" AS ENUM ()') - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: 'text', - is_unique: false, - }) - res = await pgMeta.columns.update(res.data!.id, { type: 'T' }) + let createRes = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: 'text', + is_unique: false, + }, + ]) + let res = await pgMeta.columns.update(createRes.data![0].id, { type: 'T' }) expect(res).toMatchInlineSnapshot( { data: { @@ -790,42 +1003,48 @@ test('enums are populated in enum array columns', async () => { await pgMeta.query(`create type test_enum as enum ('a')`) const { data: testTable } = await pgMeta.tables.create({ name: 't' }) - let res = await pgMeta.columns.create({ - table_id: testTable!.id, - name: 'c', - type: '_test_enum', - }) + let res = await pgMeta.columns.create([ + { + table_id: testTable!.id, + name: 'c', + type: '_test_enum', + }, + ]) expect(res).toMatchInlineSnapshot( { - data: { - id: expect.stringMatching(/^\d+\.1$/), - table_id: expect.any(Number), - }, + data: [ + { + id: expect.stringMatching(/^\d+\.1$/), + table_id: expect.any(Number), + }, + ], }, ` { - "data": { - "check": null, - "comment": null, - "data_type": "ARRAY", - "default_value": null, - "enums": [ - "a", - ], - "format": "_test_enum", - "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, - "identity_generation": null, - "is_generated": false, - "is_identity": false, - "is_nullable": true, - "is_unique": false, - "is_updatable": true, - "name": "c", - "ordinal_position": 1, - "schema": "public", - "table": "t", - "table_id": Any, - }, + "data": [ + { + "check": null, + "comment": null, + "data_type": "ARRAY", + "default_value": null, + "enums": [ + "a", + ], + "format": "_test_enum", + "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, + "identity_generation": null, + "is_generated": false, + "is_identity": false, + "is_nullable": true, + "is_unique": false, + "is_updatable": true, + "name": "c", + "ordinal_position": 1, + "schema": "public", + "table": "t", + "table_id": Any, + }, + ], "error": null, } ` diff --git a/test/lib/tables.ts b/test/lib/tables.ts index ae3dfe97..697af678 100644 --- a/test/lib/tables.ts +++ b/test/lib/tables.ts @@ -401,8 +401,10 @@ test("allow ' in comments", async () => { test('primary keys', async () => { let res = await pgMeta.tables.create({ name: 't' }) - await pgMeta.columns.create({ table_id: res.data!.id, name: 'c', type: 'int8' }) - await pgMeta.columns.create({ table_id: res.data!.id, name: 'cc', type: 'text' }) + await pgMeta.columns.create([ + { table_id: res.data!.id, name: 'c', type: 'int8' }, + { table_id: res.data!.id, name: 'cc', type: 'text' }, + ]) res = await pgMeta.tables.update(res.data!.id, { primary_keys: [{ name: 'c' }, { name: 'cc' }], })