Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions src/lib/PostgresMetaPublications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,50 @@ import { PostgresMetaResult, PostgresPublication } from './types.js'
import { PUBLICATIONS_SQL } from './sql/publications.sql.js'
import { filterByValue } from './helpers.js'

export type TableIdentifier = string | { schema: string; table: string }

// Splits a possibly schema-qualified table identifier (e.g. `schema.table`) into
// its parts, respecting double-quoted identifiers that may themselves contain
// dots or escaped double-quotes. Surrounding quotes are stripped from each part.
const splitQualifiedIdentifier = (value: string): string[] => {
const parts: string[] = []
let current = ''
let inQuotes = false

for (let i = 0; i < value.length; i++) {
const char = value[i]
if (char === '"') {
if (inQuotes && value[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (char === '.' && !inQuotes) {
parts.push(current)
current = ''
} else {
current += char
}
}
parts.push(current)
return parts
}

// Formats a table identifier for use in SQL, quoting each part with `ident`.
// A `{ schema, table }` object removes any ambiguity when the schema or table
// name itself contains dots. A string is parsed as a possibly schema-qualified
// identifier, quoting each part. E.g. `users` -> `users`, `public.users` ->
// `public.users`, `"Schema.With.Dots".table` -> `"Schema.With.Dots".table`.
const formatTableIdentifier = (t: TableIdentifier): string => {
if (typeof t === 'object') {
return `${ident(t.schema)}.${ident(t.table)}`
}
return splitQualifiedIdentifier(t)
.map((part) => ident(part))
.join('.')
}

export default class PostgresMetaPublications {
query: (sql: string) => Promise<PostgresMetaResult<any>>

Expand Down Expand Up @@ -70,25 +114,15 @@ export default class PostgresMetaPublications {
publish_update?: boolean
publish_delete?: boolean
publish_truncate?: boolean
tables?: string[] | null
tables?: TableIdentifier[] | null
}): Promise<PostgresMetaResult<PostgresPublication>> {
let tableClause: string
if (tables === undefined || tables === null) {
tableClause = 'FOR ALL TABLES'
} else if (tables.length === 0) {
tableClause = ''
} else {
tableClause = `FOR TABLE ${tables
.map((t) => {
if (!t.includes('.')) {
return ident(t)
}

const [schema, ...rest] = t.split('.')
const table = rest.join('.')
return `${ident(schema)}.${ident(table)}`
})
.join(',')}`
tableClause = `FOR TABLE ${tables.map((t) => formatTableIdentifier(t)).join(',')}`
}

let publishOps = []
Expand Down Expand Up @@ -124,7 +158,7 @@ CREATE PUBLICATION ${ident(name)} ${tableClause}
publish_update?: boolean
publish_delete?: boolean
publish_truncate?: boolean
tables?: string[] | null
tables?: TableIdentifier[] | null
}
): Promise<PostgresMetaResult<PostgresPublication>> {
const sql = `
Expand All @@ -142,19 +176,7 @@ declare
tables === undefined
? null
: literal(
tables === null
? 'all tables'
: tables
.map((t) => {
if (!t.includes('.')) {
return ident(t)
}

const [schema, ...rest] = t.split('.')
const table = rest.join('.')
return `${ident(schema)}.${ident(table)}`
})
.join(',')
tables === null ? 'all tables' : tables.map((t) => formatTableIdentifier(t)).join(',')
)
};
begin
Expand Down
6 changes: 6 additions & 0 deletions test/db/00-init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ VALUES
('Joe Bloggs'),
('Jane Doe');

-- Schema name containing dots, for testing quoted identifiers in publications
CREATE SCHEMA "NextWare.Concierge.ConciergeServices";
CREATE TABLE "NextWare.Concierge.ConciergeServices".outbox (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
);

CREATE TABLE public.todos (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
details text,
Expand Down
58 changes: 58 additions & 0 deletions test/lib/publications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,64 @@ test('tables with uppercase', async () => {
await pgMeta.tables.remove(testTableId)
})

test('tables with dotted schema names', async () => {
let res = await pgMeta.publications.create({
name: 'pub_dotted_schema',
publish_insert: true,
tables: [{ schema: 'NextWare.Concierge.ConciergeServices', table: 'outbox' }],
})
expect(cleanNondet(res)).toMatchInlineSnapshot(
{ data: { id: expect.any(Number) } },
`
{
"data": {
"id": Any<Number>,
"name": "pub_dotted_schema",
"owner": "postgres",
"publish_delete": false,
"publish_insert": true,
"publish_truncate": false,
"publish_update": false,
"tables": [
{
"name": "outbox",
"schema": "NextWare.Concierge.ConciergeServices",
},
],
},
"error": null,
}
`
)
res = await pgMeta.publications.update(res.data!.id, {
tables: ['"NextWare.Concierge.ConciergeServices".outbox'],
})
expect(cleanNondet(res)).toMatchInlineSnapshot(
{ data: { id: expect.any(Number) } },
`
{
"data": {
"id": Any<Number>,
"name": "pub_dotted_schema",
"owner": "postgres",
"publish_delete": false,
"publish_insert": true,
"publish_truncate": false,
"publish_update": false,
"tables": [
{
"name": "outbox",
"schema": "NextWare.Concierge.ConciergeServices",
},
],
},
"error": null,
}
`
)
await pgMeta.publications.remove(res.data!.id)
})

test('FOR ALL TABLES', async () => {
let res = await pgMeta.publications.create({
name: 'for_all',
Expand Down