pg-meta-columns.ts396 lines · main
| 1 | import { z } from 'zod' |
| 2 | |
| 3 | import { DEFAULT_SYSTEM_SCHEMAS } from './constants' |
| 4 | import { filterByList } from './helpers' |
| 5 | import { ident, keyword, literal, rawSql, safeSql, type SafeSqlFragment } from './pg-format' |
| 6 | import { COLUMNS_SQL } from './sql/columns' |
| 7 | |
| 8 | const pgColumnZod = z.object({ |
| 9 | id: z.string(), |
| 10 | table_id: z.number(), |
| 11 | schema: z.string(), |
| 12 | table: z.string(), |
| 13 | name: z.string(), |
| 14 | ordinal_position: z.number(), |
| 15 | data_type: z.string(), |
| 16 | format: z.string(), |
| 17 | // Optional at the type level for compatibility with column data sourced from the legacy |
| 18 | // `/platform/pg-meta/{ref}/tables` REST endpoint, which doesn't include this field. The pg-meta |
| 19 | // SQL itself always returns format_schema. |
| 20 | format_schema: z.string().optional(), |
| 21 | is_identity: z.boolean(), |
| 22 | identity_generation: z.string().nullable(), |
| 23 | is_generated: z.boolean(), |
| 24 | is_nullable: z.boolean(), |
| 25 | is_updatable: z.boolean(), |
| 26 | is_unique: z.boolean(), |
| 27 | check: z.string().nullable(), |
| 28 | default_value: z.any().nullable(), |
| 29 | enums: z.array(z.string()), |
| 30 | comment: z.string().nullable(), |
| 31 | }) |
| 32 | |
| 33 | export const pgColumnArrayZod = z.array(pgColumnZod) |
| 34 | const pgColumnOptionalZod = z.optional(pgColumnZod) |
| 35 | |
| 36 | export type PGColumn = z.infer<typeof pgColumnZod> |
| 37 | |
| 38 | function list({ |
| 39 | tableId, |
| 40 | includeSystemSchemas = false, |
| 41 | includedSchemas, |
| 42 | excludedSchemas, |
| 43 | limit, |
| 44 | offset, |
| 45 | }: { |
| 46 | tableId?: number |
| 47 | includeSystemSchemas?: boolean |
| 48 | includedSchemas?: string[] |
| 49 | excludedSchemas?: string[] |
| 50 | limit?: number |
| 51 | offset?: number |
| 52 | } = {}): { |
| 53 | sql: SafeSqlFragment |
| 54 | zod: typeof pgColumnArrayZod |
| 55 | } { |
| 56 | let sql = safeSql` |
| 57 | with |
| 58 | columns as (${COLUMNS_SQL}) |
| 59 | select |
| 60 | * |
| 61 | from |
| 62 | columns |
| 63 | where |
| 64 | true |
| 65 | ` |
| 66 | |
| 67 | const filter = filterByList( |
| 68 | includedSchemas, |
| 69 | excludedSchemas, |
| 70 | !includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined |
| 71 | ) |
| 72 | |
| 73 | if (filter) { |
| 74 | sql = safeSql`${sql} and schema ${filter}` |
| 75 | } |
| 76 | if (tableId !== undefined) { |
| 77 | sql = safeSql`${sql} and table_id = ${literal(tableId)} ` |
| 78 | } |
| 79 | if (limit) { |
| 80 | sql = safeSql`${sql} limit ${literal(limit)}` |
| 81 | } |
| 82 | if (offset) { |
| 83 | sql = safeSql`${sql} offset ${literal(offset)}` |
| 84 | } |
| 85 | return { |
| 86 | sql, |
| 87 | zod: pgColumnArrayZod, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | type ColumnIdentifier = Pick<PGColumn, 'id'> | Pick<PGColumn, 'name' | 'schema' | 'table'> |
| 92 | |
| 93 | function getIdentifierWhereClause(identifier: ColumnIdentifier): SafeSqlFragment { |
| 94 | if ('id' in identifier && identifier.id) { |
| 95 | return safeSql`${ident('id')} = ${literal(identifier.id)}` |
| 96 | } else if ('name' in identifier && identifier.name && identifier.schema && identifier.table) { |
| 97 | return safeSql`schema = ${literal(identifier.schema)} AND ${ident('table')} = ${literal(identifier.table)} AND name = ${literal(identifier.name)}` |
| 98 | } |
| 99 | throw new Error('Must provide either id or schema, name and table') |
| 100 | } |
| 101 | |
| 102 | function retrieve(identifier: ColumnIdentifier): { |
| 103 | sql: SafeSqlFragment |
| 104 | zod: typeof pgColumnOptionalZod |
| 105 | } { |
| 106 | const sql = safeSql`WITH columns AS (${COLUMNS_SQL}) SELECT * FROM columns WHERE ${getIdentifierWhereClause(identifier)};` |
| 107 | return { |
| 108 | sql, |
| 109 | zod: pgColumnOptionalZod, |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | export type ColumnTypeRef = { |
| 114 | schema?: string |
| 115 | name: string |
| 116 | isArray?: boolean |
| 117 | } |
| 118 | |
| 119 | export interface CreateOptionsBase { |
| 120 | schema: string |
| 121 | table: string |
| 122 | name: string |
| 123 | type: ColumnTypeRef |
| 124 | is_identity?: boolean |
| 125 | identity_generation?: 'BY DEFAULT' | 'ALWAYS' |
| 126 | is_nullable?: boolean |
| 127 | is_primary_key?: boolean |
| 128 | is_unique?: boolean |
| 129 | comment?: string |
| 130 | check?: string |
| 131 | no_transaction?: boolean |
| 132 | } |
| 133 | |
| 134 | type CreateOptionsDefaultExpression = { |
| 135 | default_value_format: 'expression' |
| 136 | default_value: SafeSqlFragment |
| 137 | } & CreateOptionsBase |
| 138 | |
| 139 | type CreateOptionsNotDefaultExpression = { |
| 140 | default_value_format?: 'literal' |
| 141 | default_value?: unknown |
| 142 | } & CreateOptionsBase |
| 143 | |
| 144 | type CreateOptions = CreateOptionsDefaultExpression | CreateOptionsNotDefaultExpression |
| 145 | |
| 146 | function create({ |
| 147 | schema, |
| 148 | table, |
| 149 | name, |
| 150 | type, |
| 151 | is_identity = false, |
| 152 | identity_generation = 'BY DEFAULT', |
| 153 | is_nullable, |
| 154 | is_primary_key = false, |
| 155 | is_unique = false, |
| 156 | comment, |
| 157 | check, |
| 158 | no_transaction = false, |
| 159 | ...defaultOptions |
| 160 | }: CreateOptions): { sql: SafeSqlFragment } { |
| 161 | let defaultValueClause: SafeSqlFragment = safeSql`` |
| 162 | if (is_identity) { |
| 163 | if (defaultOptions.default_value !== undefined) { |
| 164 | throw new Error('Columns cannot both be identity and have a default value') |
| 165 | } |
| 166 | |
| 167 | defaultValueClause = safeSql`GENERATED ${keyword(identity_generation)} AS IDENTITY` |
| 168 | } else { |
| 169 | if (defaultOptions.default_value === undefined) { |
| 170 | // skip |
| 171 | } else if (defaultOptions.default_value_format === 'expression') { |
| 172 | defaultValueClause = safeSql`DEFAULT ${defaultOptions.default_value}` |
| 173 | } else { |
| 174 | defaultValueClause = safeSql`DEFAULT ${literal(defaultOptions.default_value)}` |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const isNullableClause: SafeSqlFragment = |
| 179 | is_nullable === undefined ? safeSql`` : is_nullable ? safeSql`NULL` : safeSql`NOT NULL` |
| 180 | const isPrimaryKeyClause: SafeSqlFragment = is_primary_key ? safeSql`PRIMARY KEY` : safeSql`` |
| 181 | const isUniqueClause: SafeSqlFragment = is_unique ? safeSql`UNIQUE` : safeSql`` |
| 182 | const checkSql: SafeSqlFragment = |
| 183 | check === undefined ? safeSql`` : safeSql`CHECK (${rawSql(check)})` |
| 184 | const commentSql: SafeSqlFragment = |
| 185 | comment === undefined |
| 186 | ? safeSql`` |
| 187 | : safeSql`COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal(comment)}` |
| 188 | |
| 189 | const sql = safeSql` |
| 190 | ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)} |
| 191 | ${defaultValueClause} |
| 192 | ${isNullableClause} |
| 193 | ${isPrimaryKeyClause} |
| 194 | ${isUniqueClause} |
| 195 | ${checkSql}; |
| 196 | ${commentSql};` |
| 197 | |
| 198 | if (no_transaction) { |
| 199 | return { sql } |
| 200 | } |
| 201 | |
| 202 | return { |
| 203 | sql: safeSql` |
| 204 | BEGIN; |
| 205 | ${sql}; |
| 206 | COMMIT;`, |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | function typeIdent(type: ColumnTypeRef): SafeSqlFragment { |
| 211 | const base = |
| 212 | type.schema !== undefined |
| 213 | ? safeSql`${ident(type.schema)}.${ident(type.name)}` |
| 214 | : ident(type.name) |
| 215 | return type.isArray ? safeSql`${base}[]` : base |
| 216 | } |
| 217 | |
| 218 | function update( |
| 219 | old: Pick< |
| 220 | PGColumn, |
| 221 | 'name' | 'schema' | 'table' | 'table_id' | 'ordinal_position' | 'is_identity' | 'is_unique' |
| 222 | >, |
| 223 | { |
| 224 | name, |
| 225 | type, |
| 226 | drop_default = false, |
| 227 | default_value, |
| 228 | default_value_format = 'literal', |
| 229 | is_identity, |
| 230 | identity_generation = 'BY DEFAULT', |
| 231 | is_nullable, |
| 232 | is_unique, |
| 233 | comment, |
| 234 | check, |
| 235 | }: { |
| 236 | name?: string |
| 237 | type?: ColumnTypeRef |
| 238 | drop_default?: boolean |
| 239 | default_value?: any |
| 240 | default_value_format?: 'expression' | 'literal' |
| 241 | is_identity?: boolean |
| 242 | identity_generation?: 'BY DEFAULT' | 'ALWAYS' |
| 243 | is_nullable?: boolean |
| 244 | is_unique?: boolean |
| 245 | comment?: string | null |
| 246 | check?: SafeSqlFragment | null |
| 247 | } |
| 248 | ): { sql: SafeSqlFragment } { |
| 249 | const nameSql: SafeSqlFragment = |
| 250 | name === undefined || name === old.name |
| 251 | ? safeSql`` |
| 252 | : safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} RENAME COLUMN ${ident(old.name)} TO ${ident(name)};` |
| 253 | // We use USING to allow implicit conversion of incompatible types (e.g. int4 -> text). |
| 254 | const typeSql: SafeSqlFragment = |
| 255 | type === undefined |
| 256 | ? safeSql`` |
| 257 | : safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} SET DATA TYPE ${typeIdent(type)} USING ${ident(old.name)}::${typeIdent(type)};` |
| 258 | |
| 259 | let defaultValueSql: SafeSqlFragment |
| 260 | if (drop_default) { |
| 261 | defaultValueSql = safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} DROP DEFAULT;` |
| 262 | } else if (default_value === undefined) { |
| 263 | defaultValueSql = safeSql`` |
| 264 | } else { |
| 265 | const defaultValue: SafeSqlFragment = |
| 266 | default_value_format === 'expression' ? default_value : literal(default_value) |
| 267 | defaultValueSql = safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} SET DEFAULT ${defaultValue};` |
| 268 | } |
| 269 | // What identitySql does vary depending on the old and new values of |
| 270 | // is_identity and identity_generation. |
| 271 | // |
| 272 | // | is_identity: old \ new | undefined | true | false | |
| 273 | // |------------------------+--------------------+--------------------+----------------| |
| 274 | // | true | maybe set identity | maybe set identity | drop if exists | |
| 275 | // |------------------------+--------------------+--------------------+----------------| |
| 276 | // | false | - | add identity | drop if exists | |
| 277 | const alterColumnPrefix = safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)}` |
| 278 | let identitySql: SafeSqlFragment |
| 279 | if (is_identity === false) { |
| 280 | identitySql = safeSql`${alterColumnPrefix} DROP IDENTITY IF EXISTS;` |
| 281 | } else if (old.is_identity === true) { |
| 282 | if (identity_generation === undefined) { |
| 283 | identitySql = safeSql`` |
| 284 | } else { |
| 285 | identitySql = safeSql`${alterColumnPrefix} SET GENERATED ${keyword(identity_generation)};` |
| 286 | } |
| 287 | } else if (is_identity === undefined) { |
| 288 | identitySql = safeSql`` |
| 289 | } else { |
| 290 | identitySql = safeSql`${alterColumnPrefix} ADD GENERATED ${keyword(identity_generation)} AS IDENTITY;` |
| 291 | } |
| 292 | let isNullableSql: SafeSqlFragment |
| 293 | if (is_nullable === undefined) { |
| 294 | isNullableSql = safeSql`` |
| 295 | } else { |
| 296 | isNullableSql = is_nullable |
| 297 | ? safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} DROP NOT NULL;` |
| 298 | : safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(old.name)} SET NOT NULL;` |
| 299 | } |
| 300 | let isUniqueSql: SafeSqlFragment = safeSql`` |
| 301 | if (old.is_unique === true && is_unique === false) { |
| 302 | isUniqueSql = safeSql` |
| 303 | DO $$ |
| 304 | DECLARE |
| 305 | r record; |
| 306 | BEGIN |
| 307 | FOR r IN |
| 308 | SELECT conname FROM pg_constraint WHERE |
| 309 | contype = 'u' |
| 310 | AND cardinality(conkey) = 1 |
| 311 | AND conrelid = ${literal(old.table_id)} |
| 312 | AND conkey[1] = ${literal(old.ordinal_position)} |
| 313 | LOOP |
| 314 | EXECUTE ${literal(`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} DROP CONSTRAINT `)} || quote_ident(r.conname); |
| 315 | END LOOP; |
| 316 | END |
| 317 | $$;` |
| 318 | } else if (old.is_unique === false && is_unique === true) { |
| 319 | isUniqueSql = safeSql`ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD UNIQUE (${ident(old.name)});` |
| 320 | } |
| 321 | const commentSql: SafeSqlFragment = |
| 322 | comment === undefined |
| 323 | ? safeSql`` |
| 324 | : safeSql`COMMENT ON COLUMN ${ident(old.schema)}.${ident(old.table)}.${ident(old.name)} IS ${literal(comment)};` |
| 325 | |
| 326 | let checkSql: SafeSqlFragment = safeSql`` |
| 327 | if (check !== undefined) { |
| 328 | const addCheckSql: SafeSqlFragment = |
| 329 | check !== null |
| 330 | ? safeSql` |
| 331 | ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD CONSTRAINT ${ident(`${old.table}_${old.name}_check`)} CHECK (${check}); |
| 332 | |
| 333 | SELECT conkey into v_conkey FROM pg_constraint WHERE conname = ${literal(`${old.table}_${old.name}_check`)}; |
| 334 | |
| 335 | ASSERT v_conkey IS NOT NULL, 'error creating column constraint: check condition must refer to this column'; |
| 336 | ASSERT cardinality(v_conkey) = 1, 'error creating column constraint: check condition cannot refer to multiple columns'; |
| 337 | ASSERT v_conkey[1] = ${literal(old.ordinal_position)}, 'error creating column constraint: check condition cannot refer to other columns';` |
| 338 | : safeSql`` |
| 339 | checkSql = safeSql` |
| 340 | DO $$ |
| 341 | DECLARE |
| 342 | v_conname name; |
| 343 | v_conkey int2[]; |
| 344 | BEGIN |
| 345 | SELECT conname into v_conname FROM pg_constraint WHERE |
| 346 | contype = 'c' |
| 347 | AND cardinality(conkey) = 1 |
| 348 | AND conrelid = ${literal(old.table_id)} |
| 349 | AND conkey[1] = ${literal(old.ordinal_position)} |
| 350 | ORDER BY oid asc |
| 351 | LIMIT 1; |
| 352 | |
| 353 | IF v_conname IS NOT NULL THEN |
| 354 | EXECUTE format('ALTER TABLE ${ident(old.schema)}.${ident(old.table)} DROP CONSTRAINT %I', v_conname); |
| 355 | END IF; |
| 356 | ${addCheckSql} |
| 357 | END |
| 358 | $$;` |
| 359 | } |
| 360 | |
| 361 | // TODO: Can't set default if column is previously identity even if |
| 362 | // is_identity: false. Must do two separate PATCHes (once to drop identity |
| 363 | // and another to set default). |
| 364 | // NOTE: nameSql must be last. defaultValueSql must be after typeSql. |
| 365 | // identitySql must be after isNullableSql. |
| 366 | const sql = safeSql` |
| 367 | BEGIN; |
| 368 | ${isNullableSql} |
| 369 | ${typeSql} |
| 370 | ${defaultValueSql} |
| 371 | ${identitySql} |
| 372 | ${isUniqueSql} |
| 373 | ${commentSql} |
| 374 | ${checkSql} |
| 375 | ${nameSql} |
| 376 | COMMIT;` |
| 377 | |
| 378 | return { sql } |
| 379 | } |
| 380 | |
| 381 | function remove( |
| 382 | column: Pick<PGColumn, 'name' | 'schema' | 'table'>, |
| 383 | { cascade = false } = {} |
| 384 | ): { sql: SafeSqlFragment } { |
| 385 | const sql = safeSql`ALTER TABLE ${ident(column.schema)}.${ident(column.table)} DROP COLUMN ${ident(column.name)} ${cascade ? safeSql`CASCADE` : safeSql`RESTRICT`};` |
| 386 | return { sql } |
| 387 | } |
| 388 | |
| 389 | export default { |
| 390 | list, |
| 391 | retrieve, |
| 392 | create, |
| 393 | update, |
| 394 | remove, |
| 395 | zod: pgColumnZod, |
| 396 | } |