mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-08-03 15:11:44 +00:00
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:
parent
d283d8c332
commit
9abf86a5b9
2 changed files with 177 additions and 0 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue