pg-meta-publications.ts252 lines · main
| 1 | import { z } from 'zod' |
| 2 | |
| 3 | import { ident, joinSqlFragments, literal, safeSql, type SafeSqlFragment } from './pg-format' |
| 4 | import { PUBLICATIONS_SQL } from './sql/publications' |
| 5 | |
| 6 | const pgPublicationTableZod = z.object({ |
| 7 | id: z.number().optional(), |
| 8 | name: z.string(), |
| 9 | schema: z.string(), |
| 10 | }) |
| 11 | |
| 12 | const pgPublicationZod = z.object({ |
| 13 | id: z.number(), |
| 14 | name: z.string(), |
| 15 | owner: z.string(), |
| 16 | publish_insert: z.boolean(), |
| 17 | publish_update: z.boolean(), |
| 18 | publish_delete: z.boolean(), |
| 19 | publish_truncate: z.boolean(), |
| 20 | tables: z.array(pgPublicationTableZod).nullable(), |
| 21 | }) |
| 22 | |
| 23 | const pgPublicationArrayZod = z.array(pgPublicationZod) |
| 24 | const pgPublicationOptionalZod = z.optional(pgPublicationZod) |
| 25 | |
| 26 | export type PGPublication = z.infer<typeof pgPublicationZod> |
| 27 | |
| 28 | function list({ |
| 29 | limit, |
| 30 | offset, |
| 31 | }: { |
| 32 | limit?: number |
| 33 | offset?: number |
| 34 | } = {}): { |
| 35 | sql: SafeSqlFragment |
| 36 | zod: typeof pgPublicationArrayZod |
| 37 | } { |
| 38 | let sql = safeSql`with publications as (${PUBLICATIONS_SQL}) select * from publications` |
| 39 | if (limit) { |
| 40 | sql = safeSql`${sql} limit ${literal(limit)}` |
| 41 | } |
| 42 | if (offset) { |
| 43 | sql = safeSql`${sql} offset ${literal(offset)}` |
| 44 | } |
| 45 | return { |
| 46 | sql, |
| 47 | zod: pgPublicationArrayZod, |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | type PublicationIdentifier = Pick<PGPublication, 'id'> | Pick<PGPublication, 'name'> |
| 52 | |
| 53 | function getIdentifierWhereClause(identifier: PublicationIdentifier): SafeSqlFragment { |
| 54 | if ('id' in identifier && identifier.id) { |
| 55 | return safeSql`${ident('id')} = ${literal(identifier.id)}` |
| 56 | } else if ('name' in identifier && identifier.name) { |
| 57 | return safeSql`${ident('name')} = ${literal(identifier.name)}` |
| 58 | } |
| 59 | throw new Error('Must provide either id or name') |
| 60 | } |
| 61 | |
| 62 | function retrieve(identifier: PublicationIdentifier): { |
| 63 | sql: SafeSqlFragment |
| 64 | zod: typeof pgPublicationOptionalZod |
| 65 | } { |
| 66 | const sql = safeSql`with publications as (${PUBLICATIONS_SQL}) select * from publications where ${getIdentifierWhereClause(identifier)};` |
| 67 | return { |
| 68 | sql, |
| 69 | zod: pgPublicationOptionalZod, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | type PublicationCreateParams = { |
| 74 | name: string |
| 75 | publish_insert?: boolean |
| 76 | publish_update?: boolean |
| 77 | publish_delete?: boolean |
| 78 | publish_truncate?: boolean |
| 79 | tables?: string[] | null |
| 80 | } |
| 81 | |
| 82 | function create({ |
| 83 | name, |
| 84 | publish_insert = false, |
| 85 | publish_update = false, |
| 86 | publish_delete = false, |
| 87 | publish_truncate = false, |
| 88 | tables = null, |
| 89 | }: PublicationCreateParams): { sql: SafeSqlFragment } { |
| 90 | let tableClause: SafeSqlFragment |
| 91 | if (tables === undefined || tables === null) { |
| 92 | tableClause = safeSql`FOR ALL TABLES` |
| 93 | } else if (tables.length === 0) { |
| 94 | tableClause = safeSql`` |
| 95 | } else { |
| 96 | tableClause = safeSql`FOR TABLE ${joinSqlFragments( |
| 97 | tables.map((t) => { |
| 98 | if (!t.includes('.')) { |
| 99 | return ident(t) |
| 100 | } |
| 101 | const [schema, ...rest] = t.split('.') |
| 102 | const table = rest.join('.') |
| 103 | return safeSql`${ident(schema)}.${ident(table)}` |
| 104 | }), |
| 105 | ',' |
| 106 | )}` |
| 107 | } |
| 108 | |
| 109 | const publishOps: Array<string> = [] |
| 110 | if (publish_insert) publishOps.push('insert') |
| 111 | if (publish_update) publishOps.push('update') |
| 112 | if (publish_delete) publishOps.push('delete') |
| 113 | if (publish_truncate) publishOps.push('truncate') |
| 114 | |
| 115 | const sql = safeSql` |
| 116 | CREATE PUBLICATION ${ident(name)} ${tableClause} |
| 117 | WITH (publish = ${literal(publishOps.join(','))});` |
| 118 | |
| 119 | return { sql } |
| 120 | } |
| 121 | |
| 122 | type PublicationUpdateParams = { |
| 123 | name?: string |
| 124 | owner?: string |
| 125 | publish_insert?: boolean |
| 126 | publish_update?: boolean |
| 127 | publish_delete?: boolean |
| 128 | publish_truncate?: boolean |
| 129 | tables?: string[] | null |
| 130 | } |
| 131 | |
| 132 | function update( |
| 133 | id: number, |
| 134 | { |
| 135 | name, |
| 136 | owner, |
| 137 | publish_insert, |
| 138 | publish_update, |
| 139 | publish_delete, |
| 140 | publish_truncate, |
| 141 | tables, |
| 142 | }: PublicationUpdateParams |
| 143 | ): { sql: SafeSqlFragment } { |
| 144 | const sql = safeSql` |
| 145 | do $$ |
| 146 | declare |
| 147 | id oid := ${literal(id)}; |
| 148 | old record; |
| 149 | new_name text := ${name === undefined ? literal(null) : literal(name)}; |
| 150 | new_owner text := ${owner === undefined ? literal(null) : literal(owner)}; |
| 151 | new_publish_insert bool := ${literal(publish_insert ?? null)}; |
| 152 | new_publish_update bool := ${literal(publish_update ?? null)}; |
| 153 | new_publish_delete bool := ${literal(publish_delete ?? null)}; |
| 154 | new_publish_truncate bool := ${literal(publish_truncate ?? null)}; |
| 155 | new_tables text := ${ |
| 156 | tables === undefined |
| 157 | ? literal(null) |
| 158 | : literal( |
| 159 | tables === null |
| 160 | ? 'all tables' |
| 161 | : tables |
| 162 | .map((t) => { |
| 163 | if (!t.includes('.')) { |
| 164 | return ident(t) |
| 165 | } |
| 166 | |
| 167 | const [schema, ...rest] = t.split('.') |
| 168 | const table = rest.join('.') |
| 169 | return safeSql`${ident(schema)}.${ident(table)}` |
| 170 | }) |
| 171 | .join(',') |
| 172 | ) |
| 173 | }; |
| 174 | begin |
| 175 | select * into old from pg_publication where oid = id; |
| 176 | if old is null then |
| 177 | raise exception 'Cannot find publication with id %', id; |
| 178 | end if; |
| 179 | |
| 180 | if new_tables is null then |
| 181 | null; |
| 182 | elsif new_tables = 'all tables' then |
| 183 | if old.puballtables then |
| 184 | null; |
| 185 | else |
| 186 | -- Need to recreate because going from list of tables <-> all tables with alter is not possible. |
| 187 | execute(format('drop publication %1$I; create publication %1$I for all tables;', old.pubname)); |
| 188 | end if; |
| 189 | else |
| 190 | if old.puballtables then |
| 191 | -- Need to recreate because going from list of tables <-> all tables with alter is not possible. |
| 192 | execute(format('drop publication %1$I; create publication %1$I;', old.pubname)); |
| 193 | elsif exists(select from pg_publication_rel where prpubid = id) then |
| 194 | execute( |
| 195 | format( |
| 196 | 'alter publication %I drop table %s', |
| 197 | old.pubname, |
| 198 | (select string_agg(prrelid::regclass::text, ', ') from pg_publication_rel where prpubid = id) |
| 199 | ) |
| 200 | ); |
| 201 | end if; |
| 202 | |
| 203 | -- At this point the publication must have no tables. |
| 204 | |
| 205 | if new_tables != '' then |
| 206 | execute(format('alter publication %I add table %s', old.pubname, new_tables)); |
| 207 | end if; |
| 208 | end if; |
| 209 | |
| 210 | execute( |
| 211 | format( |
| 212 | 'alter publication %I set (publish = %L);', |
| 213 | old.pubname, |
| 214 | concat_ws( |
| 215 | ', ', |
| 216 | case when coalesce(new_publish_insert, old.pubinsert) then 'insert' end, |
| 217 | case when coalesce(new_publish_update, old.pubupdate) then 'update' end, |
| 218 | case when coalesce(new_publish_delete, old.pubdelete) then 'delete' end, |
| 219 | case when coalesce(new_publish_truncate, old.pubtruncate) then 'truncate' end |
| 220 | ) |
| 221 | ) |
| 222 | ); |
| 223 | |
| 224 | execute(format('alter publication %I owner to %I;', old.pubname, coalesce(new_owner, old.pubowner::regrole::name))); |
| 225 | |
| 226 | -- Using the same name in the rename clause gives an error, so only do it if the new name is different. |
| 227 | if new_name is not null and new_name != old.pubname then |
| 228 | execute(format('alter publication %I rename to %I;', old.pubname, coalesce(new_name, old.pubname))); |
| 229 | end if; |
| 230 | |
| 231 | -- We need to retrieve the publication later, so we need a way to uniquely identify which publication this is. |
| 232 | -- We can't rely on id because it gets changed if it got recreated. |
| 233 | -- We use a temp table to store the unique name - DO blocks can't return a value. |
| 234 | create temp table pg_meta_publication_tmp (name) on commit drop as values (coalesce(new_name, old.pubname)); |
| 235 | end $$; |
| 236 | ` |
| 237 | return { sql } |
| 238 | } |
| 239 | |
| 240 | function remove(publication: Pick<PGPublication, 'name'>): { sql: SafeSqlFragment } { |
| 241 | const sql = safeSql`DROP PUBLICATION IF EXISTS ${ident(publication.name)};` |
| 242 | return { sql } |
| 243 | } |
| 244 | |
| 245 | export default { |
| 246 | list, |
| 247 | retrieve, |
| 248 | create, |
| 249 | update, |
| 250 | remove, |
| 251 | zod: pgPublicationZod, |
| 252 | } |