Skip to content

Commit f5a6782

Browse files
committedMar 20, 2022
feat: batch endpoints for column creation and retrieval
1 parent eaf321f commit f5a6782

File tree

3 files changed

+290
-53
lines changed

3 files changed

+290
-53
lines changed
 

‎src/lib/PostgresMetaColumns.ts

+121-52
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ import { DEFAULT_SYSTEM_SCHEMAS } from './constants'
44
import { columnsSql } from './sql'
55
import { PostgresMetaResult, PostgresColumn } from './types'
66

7+
interface ColumnCreationRequest {
8+
table_id: number
9+
name: string
10+
type: string
11+
default_value?: any
12+
default_value_format?: 'expression' | 'literal'
13+
is_identity?: boolean
14+
identity_generation?: 'BY DEFAULT' | 'ALWAYS'
15+
is_nullable?: boolean
16+
is_primary_key?: boolean
17+
is_unique?: boolean
18+
comment?: string
19+
check?: string
20+
}
21+
22+
interface ColumnBatchInfoRequest {
23+
ids?: string[]
24+
names?: string[]
25+
table?: string
26+
schema?: string
27+
}
28+
729
export default class PostgresMetaColumns {
830
query: (sql: string) => Promise<PostgresMetaResult<any>>
931
metaTables: PostgresMetaTables
@@ -57,75 +79,130 @@ export default class PostgresMetaColumns {
5779
schema?: string
5880
}): Promise<PostgresMetaResult<PostgresColumn>> {
5981
if (id) {
60-
const regexp = /^(\d+)\.(\d+)$/
61-
if (!regexp.test(id)) {
62-
return { data: null, error: { message: 'Invalid format for column ID' } }
82+
const { data, error } = await this.batchRetrieve({ ids: [id] })
83+
if (data) {
84+
return { data: data[0], error: null }
85+
} else if (error) {
86+
return { data: null, error: error }
87+
}
88+
}
89+
if (name && table) {
90+
const { data, error } = await this.batchRetrieve({ names: [name], table, schema })
91+
if (data) {
92+
return { data: data[0], error: null }
93+
} else if (error) {
94+
return { data: null, error: error }
6395
}
64-
const matches = id.match(regexp) as RegExpMatchArray
65-
const [tableId, ordinalPos] = matches.slice(1).map(Number)
66-
const sql = `${columnsSql} AND c.oid = ${tableId} AND a.attnum = ${ordinalPos};`
96+
}
97+
return { data: null, error: { message: 'Invalid parameters on column retrieve' } }
98+
}
99+
100+
async batchRetrieve({
101+
ids,
102+
names,
103+
table,
104+
schema = 'public',
105+
}: ColumnBatchInfoRequest): Promise<PostgresMetaResult<PostgresColumn[]>> {
106+
if (ids && ids.length > 0) {
107+
const regexp = /^(\d+)\.(\d+)$/
108+
const filteringClauses = ids
109+
.map((id) => {
110+
if (!regexp.test(id)) {
111+
return { data: null, error: { message: 'Invalid format for column ID' } }
112+
}
113+
const matches = id.match(regexp) as RegExpMatchArray
114+
const [tableId, ordinalPos] = matches.slice(1).map(Number)
115+
return `(c.oid = ${tableId} AND a.attnum = ${ordinalPos})`
116+
})
117+
.join(' OR ')
118+
const sql = `${columnsSql} AND (${filteringClauses});`
67119
const { data, error } = await this.query(sql)
68120
if (error) {
69121
return { data, error }
70-
} else if (data.length === 0) {
71-
return { data: null, error: { message: `Cannot find a column with ID ${id}` } }
122+
} else if (data.length < ids.length) {
123+
return { data: null, error: { message: `Cannot find some of the requested columns.` } }
72124
} else {
73-
return { data: data[0], error }
125+
return { data, error }
74126
}
75-
} else if (name && table) {
76-
const sql = `${columnsSql} AND a.attname = ${literal(name)} AND c.relname = ${literal(
127+
} else if (names && names.length > 0 && table) {
128+
const filteringClauses = names.map((name) => `a.attname = ${literal(name)}`).join(' OR ')
129+
const sql = `${columnsSql} AND (${filteringClauses}) AND c.relname = ${literal(
77130
table
78131
)} AND nc.nspname = ${literal(schema)};`
79132
const { data, error } = await this.query(sql)
80133
if (error) {
81134
return { data, error }
82-
} else if (data.length === 0) {
135+
} else if (data.length < names.length) {
83136
return {
84137
data: null,
85-
error: { message: `Cannot find a column named ${name} in table ${schema}.${table}` },
138+
error: { message: `Cannot find some of the requested columns.` },
86139
}
87140
} else {
88-
return { data: data[0], error }
141+
return { data, error }
89142
}
90143
} else {
91144
return { data: null, error: { message: 'Invalid parameters on column retrieve' } }
92145
}
93146
}
94147

95-
async create({
96-
table_id,
97-
name,
98-
type,
99-
default_value,
100-
default_value_format = 'literal',
101-
is_identity = false,
102-
identity_generation = 'BY DEFAULT',
103-
// Can't pick a value as default since regular columns are nullable by default but PK columns aren't
104-
is_nullable,
105-
is_primary_key = false,
106-
is_unique = false,
107-
comment,
108-
check,
109-
}: {
110-
table_id: number
111-
name: string
112-
type: string
113-
default_value?: any
114-
default_value_format?: 'expression' | 'literal'
115-
is_identity?: boolean
116-
identity_generation?: 'BY DEFAULT' | 'ALWAYS'
117-
is_nullable?: boolean
118-
is_primary_key?: boolean
119-
is_unique?: boolean
120-
comment?: string
121-
check?: string
122-
}): Promise<PostgresMetaResult<PostgresColumn>> {
148+
async create(col: ColumnCreationRequest): Promise<PostgresMetaResult<PostgresColumn>> {
149+
const { data, error } = await this.batchCreate([col])
150+
if (data) {
151+
return { data: data[0], error: null }
152+
} else if (error) {
153+
return { data: null, error: error }
154+
}
155+
return { data: null, error: { message: 'Invalid params' } }
156+
}
157+
158+
async batchCreate(cols: ColumnCreationRequest[]): Promise<PostgresMetaResult<PostgresColumn[]>> {
159+
if (cols.length < 1) {
160+
throw new Error('no columns provided for creation')
161+
}
162+
if ([...new Set(cols.map((col) => col.table_id))].length > 1) {
163+
throw new Error('all columns in a single request must share the same table')
164+
}
165+
const { table_id } = cols[0]
123166
const { data, error } = await this.metaTables.retrieve({ id: table_id })
124167
if (error) {
125168
return { data: null, error }
126169
}
127170
const { name: table, schema } = data!
128171

172+
const sqlStrings = cols.map((col) => this.generateColumnCreationSql(col, schema, table))
173+
174+
const sql = `BEGIN;
175+
${sqlStrings.join('\n')}
176+
COMMIT;
177+
`
178+
{
179+
const { error } = await this.query(sql)
180+
if (error) {
181+
return { data: null, error }
182+
}
183+
}
184+
const names = cols.map((col) => col.name)
185+
return await this.batchRetrieve({ names, table, schema })
186+
}
187+
188+
generateColumnCreationSql(
189+
{
190+
name,
191+
type,
192+
default_value,
193+
default_value_format = 'literal',
194+
is_identity = false,
195+
identity_generation = 'BY DEFAULT',
196+
// Can't pick a value as default since regular columns are nullable by default but PK columns aren't
197+
is_nullable,
198+
is_primary_key = false,
199+
is_unique = false,
200+
comment,
201+
check,
202+
}: ColumnCreationRequest,
203+
schema: string,
204+
table: string
205+
) {
129206
let defaultValueClause = ''
130207
if (is_identity) {
131208
if (default_value !== undefined) {
@@ -159,22 +236,14 @@ export default class PostgresMetaColumns {
159236
: `COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal(comment)}`
160237

161238
const sql = `
162-
BEGIN;
163239
ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)}
164240
${defaultValueClause}
165241
${isNullableClause}
166242
${isPrimaryKeyClause}
167243
${isUniqueClause}
168244
${checkSql};
169-
${commentSql};
170-
COMMIT;`
171-
{
172-
const { error } = await this.query(sql)
173-
if (error) {
174-
return { data: null, error }
175-
}
176-
}
177-
return await this.retrieve({ name, table, schema })
245+
${commentSql};`
246+
return sql
178247
}
179248

180249
async update(

‎src/server/routes/columns.ts

+38
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default async (fastify: FastifyInstance) => {
3333
return data
3434
})
3535

36+
// deprecated: use GET /batch instead
3637
fastify.get<{
3738
Headers: { pg: string }
3839
Params: {
@@ -54,6 +55,26 @@ export default async (fastify: FastifyInstance) => {
5455
return data
5556
})
5657

58+
fastify.get<{
59+
Headers: { pg: string }
60+
Body: any
61+
}>('/batch', async (request, reply) => {
62+
const connectionString = request.headers.pg
63+
const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
64+
const { data, error } = await pgMeta.columns.batchRetrieve({ ids: request.body })
65+
await pgMeta.end()
66+
if (error) {
67+
request.log.error({ error, request: extractRequestForLogging(request) })
68+
reply.code(400)
69+
if (error.message.startsWith('Cannot find')) reply.code(404)
70+
return { error: error.message }
71+
}
72+
73+
return data
74+
})
75+
76+
// deprecated: use POST /batch instead
77+
// TODO (darora): specifying a schema on the routes would both allow for validation, and enable us to mark methods as deprecated
5778
fastify.post<{
5879
Headers: { pg: string }
5980
Body: any
@@ -69,7 +90,24 @@ export default async (fastify: FastifyInstance) => {
6990
if (error.message.startsWith('Cannot find')) reply.code(404)
7091
return { error: error.message }
7192
}
93+
return data
94+
})
95+
96+
fastify.post<{
97+
Headers: { pg: string }
98+
Body: any
99+
}>('/batch', async (request, reply) => {
100+
const connectionString = request.headers.pg
72101

102+
const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
103+
const { data, error } = await pgMeta.columns.batchCreate(request.body)
104+
await pgMeta.end()
105+
if (error) {
106+
request.log.error({ error, request: extractRequestForLogging(request) })
107+
reply.code(400)
108+
if (error.message.startsWith('Cannot find')) reply.code(404)
109+
return { error: error.message }
110+
}
73111
return data
74112
})
75113

‎test/lib/columns.ts

+131-1
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,143 @@ test('retrieve, create, update, delete', async () => {
170170
expect(res).toMatchObject({
171171
data: null,
172172
error: {
173-
message: expect.stringMatching(/^Cannot find a column with ID \d+.1$/),
173+
message: expect.stringMatching(/^Cannot find some of the requested columns.$/),
174174
},
175175
})
176176

177177
await pgMeta.tables.remove(testTable!.id)
178178
})
179179

180+
test('batch endpoints for create and retrieve', async () => {
181+
const { data: testTable }: any = await pgMeta.tables.create({ name: 't' })
182+
183+
let res = await pgMeta.columns.batchCreate([
184+
{
185+
table_id: testTable!.id,
186+
name: 'c1',
187+
type: 'int2',
188+
default_value: 42,
189+
comment: 'foo',
190+
},
191+
{
192+
table_id: testTable!.id,
193+
name: 'c2',
194+
type: 'int2',
195+
default_value: 41,
196+
comment: 'bar',
197+
},
198+
])
199+
expect(res).toMatchInlineSnapshot(
200+
{
201+
data: [
202+
{ id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) },
203+
{ id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) },
204+
],
205+
},
206+
`
207+
Object {
208+
"data": Array [
209+
Object {
210+
"comment": "foo",
211+
"data_type": "smallint",
212+
"default_value": "'42'::smallint",
213+
"enums": Array [],
214+
"format": "int2",
215+
"id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/,
216+
"identity_generation": null,
217+
"is_generated": false,
218+
"is_identity": false,
219+
"is_nullable": true,
220+
"is_unique": false,
221+
"is_updatable": true,
222+
"name": "c1",
223+
"ordinal_position": 1,
224+
"schema": "public",
225+
"table": "t",
226+
"table_id": Any<Number>,
227+
},
228+
Object {
229+
"comment": "bar",
230+
"data_type": "smallint",
231+
"default_value": "'41'::smallint",
232+
"enums": Array [],
233+
"format": "int2",
234+
"id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/,
235+
"identity_generation": null,
236+
"is_generated": false,
237+
"is_identity": false,
238+
"is_nullable": true,
239+
"is_unique": false,
240+
"is_updatable": true,
241+
"name": "c2",
242+
"ordinal_position": 2,
243+
"schema": "public",
244+
"table": "t",
245+
"table_id": Any<Number>,
246+
},
247+
],
248+
"error": null,
249+
}
250+
`
251+
)
252+
res = await pgMeta.columns.batchRetrieve({ ids: [res.data![0].id, res.data![1].id] })
253+
expect(res).toMatchInlineSnapshot(
254+
{
255+
data: [
256+
{ id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) },
257+
{ id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) },
258+
],
259+
},
260+
`
261+
Object {
262+
"data": Array [
263+
Object {
264+
"comment": "foo",
265+
"data_type": "smallint",
266+
"default_value": "'42'::smallint",
267+
"enums": Array [],
268+
"format": "int2",
269+
"id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/,
270+
"identity_generation": null,
271+
"is_generated": false,
272+
"is_identity": false,
273+
"is_nullable": true,
274+
"is_unique": false,
275+
"is_updatable": true,
276+
"name": "c1",
277+
"ordinal_position": 1,
278+
"schema": "public",
279+
"table": "t",
280+
"table_id": Any<Number>,
281+
},
282+
Object {
283+
"comment": "bar",
284+
"data_type": "smallint",
285+
"default_value": "'41'::smallint",
286+
"enums": Array [],
287+
"format": "int2",
288+
"id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/,
289+
"identity_generation": null,
290+
"is_generated": false,
291+
"is_identity": false,
292+
"is_nullable": true,
293+
"is_unique": false,
294+
"is_updatable": true,
295+
"name": "c2",
296+
"ordinal_position": 2,
297+
"schema": "public",
298+
"table": "t",
299+
"table_id": Any<Number>,
300+
},
301+
],
302+
"error": null,
303+
}
304+
`
305+
)
306+
307+
await pgMeta.tables.remove(testTable!.id)
308+
})
309+
180310
test('enum column with quoted name', async () => {
181311
await pgMeta.query('CREATE TYPE "T" AS ENUM (\'v\'); CREATE TABLE t ( c "T" );')
182312

0 commit comments

Comments
 (0)
Please sign in to comment.