add postgres denormalization triggers for library items

The sqlite triggers that keep libraryItems.title[IgnorePrefix] and
authorNamesFirstLast/LastFirst fresh were skipped on postgres, so title
edits, author renames, and bookAuthor changes left stale sort/search
columns. Mirror them with native pg trigger functions (string_agg in
place of GROUP_CONCAT) using folded lowercase identifiers, and keep the
same trigger names and existence checks. Validated against PostgreSQL
14: title propagation, author insert/delete/rename, createdat ordering,
and null-on-empty all match sqlite semantics.
This commit is contained in:
Kevin Gatera 2026-08-02 18:39:27 -04:00
parent 4d7d7832ef
commit 3e86d6ef50
No known key found for this signature in database
GPG key ID: F0D9F5932458CFB9
2 changed files with 177 additions and 0 deletions

View file

@ -928,6 +928,9 @@ WHERE EXISTS (
* It adds triggers to update libraryItems.title[IgnorePrefix] when (books|podcasts).title[IgnorePrefix] is updated
*/
async addTriggers() {
if (this.isPostgresDialect()) {
return this.addPostgresTriggers()
}
if (!this.isSqliteDialect()) {
Logger.info(`[Database] Skipping sqlite-only triggers for dialect ${this.dialect}`)
return
@ -1030,6 +1033,121 @@ WHERE EXISTS (
await addAuthorsUpdateTriggerIfNotExists()
}
/**
* Postgres equivalent of the sqlite libraryItems denormalization triggers above.
* Identifiers are unquoted (and therefore folded to lowercase by postgres) to match
* the quoteIdentifiers: false strategy used for the postgres dialect.
*/
async addPostgresTriggers() {
Logger.info('[Database] Adding postgres denormalization triggers')
await this.addPostgresTitleTriggerIfNotExists('books', 'title')
await this.addPostgresTitleTriggerIfNotExists('books', 'titleIgnorePrefix')
await this.addPostgresTitleTriggerIfNotExists('podcasts', 'title')
await this.addPostgresTitleTriggerIfNotExists('podcasts', 'titleIgnorePrefix')
await this.addPostgresAuthorNamesTriggersIfNotExist()
}
async postgresTriggerExists(triggerName) {
const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM pg_trigger WHERE NOT tgisinternal AND tgname = '${triggerName}'`)
return Number(count) > 0
}
async addPostgresTitleTriggerIfNotExists(sourceTable, sourceColumn) {
const foldedColumn = sourceColumn.toLowerCase()
const action = `update_libraryItems_${sourceColumn}`
const fromSource = sourceTable === 'books' ? '' : `_from_${sourceTable}_${sourceColumn}`
const triggerName = this.convertToSnakeCase(`${action}${fromSource}`)
const functionName = `${triggerName}_fn`
if (await this.postgresTriggerExists(triggerName)) return // Trigger already exists
Logger.info(`[Database] Adding trigger ${triggerName}`)
await this.sequelize.query(`
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger AS $func$
BEGIN
UPDATE libraryitems
SET ${foldedColumn} = NEW.${foldedColumn}
WHERE mediaid = NEW.id;
RETURN NEW;
END;
$func$ LANGUAGE plpgsql
`)
await this.sequelize.query(`
CREATE TRIGGER ${triggerName}
AFTER UPDATE OF ${foldedColumn} ON ${sourceTable}
FOR EACH ROW
EXECUTE FUNCTION ${functionName}()
`)
}
async addPostgresAuthorNamesTriggersIfNotExist() {
// string_agg is the postgres equivalent of sqlite GROUP_CONCAT; both return NULL for an empty set
const authorNamesSubQuery = (bookIdExpression) => `
SELECT string_agg(authors.name, ', ' ORDER BY bookauthors.createdat ASC), string_agg(authors.lastfirst, ', ' ORDER BY bookauthors.createdat ASC)
FROM authors JOIN bookauthors ON authors.id = bookauthors.authorid
WHERE bookauthors.bookid = ${bookIdExpression}
`
const addBookAuthorsTriggerIfNotExists = async (action) => {
const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'
const triggerName = this.convertToSnakeCase(`update_libraryItems_authorNames_on_bookAuthors_${action}`)
const functionName = `${triggerName}_fn`
if (await this.postgresTriggerExists(triggerName)) return // Trigger already exists
Logger.info(`[Database] Adding trigger ${triggerName}`)
await this.sequelize.query(`
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger AS $func$
BEGIN
UPDATE libraryitems
SET (authornamesfirstlast, authornameslastfirst) = (${authorNamesSubQuery(`${modifiedRecord}.bookid`)})
WHERE mediaid = ${modifiedRecord}.bookid;
RETURN ${modifiedRecord};
END;
$func$ LANGUAGE plpgsql
`)
await this.sequelize.query(`
CREATE TRIGGER ${triggerName}
AFTER ${action.toUpperCase()} ON bookauthors
FOR EACH ROW
EXECUTE FUNCTION ${functionName}()
`)
}
const addAuthorsUpdateTriggerIfNotExists = async () => {
const triggerName = this.convertToSnakeCase('update_libraryItems_authorNames_on_authors_update')
const functionName = `${triggerName}_fn`
if (await this.postgresTriggerExists(triggerName)) return // Trigger already exists
Logger.info(`[Database] Adding trigger ${triggerName}`)
await this.sequelize.query(`
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger AS $func$
BEGIN
UPDATE libraryitems
SET (authornamesfirstlast, authornameslastfirst) = (${authorNamesSubQuery('libraryitems.mediaid')})
WHERE mediaid IN (SELECT bookid FROM bookauthors WHERE authorid = NEW.id);
RETURN NEW;
END;
$func$ LANGUAGE plpgsql
`)
await this.sequelize.query(`
CREATE TRIGGER ${triggerName}
AFTER UPDATE OF name ON authors
FOR EACH ROW
EXECUTE FUNCTION ${functionName}()
`)
}
await addBookAuthorsTriggerIfNotExists('insert')
await addBookAuthorsTriggerIfNotExists('delete')
await addAuthorsUpdateTriggerIfNotExists()
}
convertToSnakeCase(str) {
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
}

View file

@ -98,4 +98,63 @@ describe('Database', () => {
expect(hasTables).to.equal(false)
})
})
describe('addPostgresTriggers', () => {
function captureQueries(existingTriggers = []) {
const queries = []
Database.sequelize = {
query: async (sql) => {
queries.push(sql)
const count = existingTriggers.filter((name) => sql.includes(`tgname = '${name}'`)).length
return [[{ count }]]
}
}
return queries
}
it('should create title and author names triggers with folded lowercase identifiers', async () => {
const queries = captureQueries()
await Database.addPostgresTriggers()
const functions = queries.filter((sql) => sql.includes('CREATE OR REPLACE FUNCTION'))
const triggers = queries.filter((sql) => sql.includes('CREATE TRIGGER'))
expect(functions.length).to.equal(7)
expect(triggers.length).to.equal(7)
const allDdl = [...functions, ...triggers].join('\n')
// No camelCase identifiers may leak into postgres DDL - unquoted identifiers fold to lowercase
expect(allDdl).to.not.match(/libraryItems|bookAuthors|mediaId|titleIgnorePrefix|authorNames|bookId|authorId|lastFirst|createdAt/)
const authorTrigger = triggers.find((sql) => sql.includes('update_library_items_author_names_on_authors_update'))
expect(authorTrigger).to.include('AFTER UPDATE OF name ON authors')
const insertFn = functions.find((sql) => sql.includes('update_library_items_author_names_on_book_authors_insert_fn'))
expect(insertFn).to.include("string_agg(authors.name, ', ' ORDER BY bookauthors.createdat ASC)")
expect(insertFn).to.include('WHERE mediaid = NEW.bookid')
const deleteFn = functions.find((sql) => sql.includes('update_library_items_author_names_on_book_authors_delete_fn'))
expect(deleteFn).to.include('WHERE mediaid = OLD.bookid')
})
it('should skip triggers that already exist', async () => {
const queries = captureQueries(['update_library_items_title'])
await Database.addPostgresTriggers()
const titleFn = queries.find((sql) => sql.includes('update_library_items_title_fn'))
expect(titleFn).to.equal(undefined)
const otherFns = queries.filter((sql) => sql.includes('CREATE OR REPLACE FUNCTION'))
expect(otherFns.length).to.equal(6)
})
it('should dispatch to postgres triggers from addTriggers', async () => {
Database.dialect = 'postgres'
const queries = captureQueries()
await Database.addTriggers()
expect(queries.some((sql) => sql.includes('CREATE TRIGGER'))).to.equal(true)
})
})
})