From 9abf86a5b96b218b0d2e90f54bc81cb0e5db4d1c Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Sun, 2 Aug 2026 18:39:27 -0400 Subject: [PATCH] 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. --- server/Database.js | 118 +++++++++++++++++++++++++++++++++++ test/server/Database.test.js | 59 ++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/server/Database.js b/server/Database.js index 247c7e974..df0d6939b 100644 --- a/server/Database.js +++ b/server/Database.js @@ -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() } diff --git a/test/server/Database.test.js b/test/server/Database.test.js index a70e1d204..047d42a31 100644 --- a/test/server/Database.test.js +++ b/test/server/Database.test.js @@ -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) + }) + }) })