perf: replace browse count predicates with exists checks

This commit is contained in:
Jonathan Finley 2026-03-13 22:11:29 -04:00
parent 6788943087
commit c164831722
2 changed files with 77 additions and 29 deletions

View file

@ -11,6 +11,42 @@ const { loadBrowseCount } = require('./libraryBrowseCount')
const { encodeBrowseCursor, decodeBrowseCursor } = require('./libraryBrowseCursor')
const countCache = new Map()
function existsLiteral(sql) {
return Sequelize.where(Sequelize.literal(`EXISTS (${sql})`), true)
}
function notExistsLiteral(sql) {
return Sequelize.where(Sequelize.literal(`NOT EXISTS (${sql})`), true)
}
function hasNamedBookValue(fieldName, replacementKey = 'filterValue') {
return existsLiteral(`SELECT 1 FROM json_each(${fieldName}) WHERE json_valid(${fieldName}) AND json_each.value = :${replacementKey}`)
}
function hasAnyJsonValue(fieldName) {
return existsLiteral(`SELECT 1 FROM json_each(${fieldName}) WHERE json_valid(${fieldName})`)
}
function hasNoJsonValue(fieldName) {
return notExistsLiteral(`SELECT 1 FROM json_each(${fieldName}) WHERE json_valid(${fieldName})`)
}
function hasAtLeastTwoJsonValues(fieldName) {
return existsLiteral(`SELECT 1 FROM json_each(${fieldName}) WHERE json_valid(${fieldName}) LIMIT 1 OFFSET 1`)
}
function hasExactlyOneJsonValue(fieldName) {
return [hasAnyJsonValue(fieldName), notExistsLiteral(`SELECT 1 FROM json_each(${fieldName}) WHERE json_valid(${fieldName}) LIMIT 1 OFFSET 1`)]
}
function hasScalarValue(columnName) {
return existsLiteral(`SELECT 1 WHERE ${columnName} IS NOT NULL`)
}
function hasNoScalarValue(columnName) {
return notExistsLiteral(`SELECT 1 WHERE ${columnName} IS NOT NULL`)
}
function normalizeCursorValue(key, value) {
if (value == null) return value
@ -48,7 +84,7 @@ function getCursorComparisonExpression(key) {
const quotedColumn = getQuotedCursorColumn(key)
if (['title', 'titleIgnorePrefix', 'authorNamesFirstLast', 'authorNamesLastFirst'].includes(key)) {
return Sequelize.literal(dialectHelpers.getCaseInsensitiveOrder(Database.getDialect(), quotedColumn, false))
return Sequelize.literal(`LOWER(${quotedColumn})`)
}
return Sequelize.literal(quotedColumn)
@ -147,21 +183,21 @@ module.exports = {
const replacements = {}
if (!user) return { bookWhere, replacements }
const accessAllTags = user.permissions?.accessAllTags ?? user.accessAllTags
const itemTagsSelected = user.permissions?.itemTagsSelected ?? user.itemTagsSelected
const selectedTagsNotAccessible = user.permissions?.selectedTagsNotAccessible ?? user.selectedTagsNotAccessible
if (!user.canAccessExplicitContent) {
bookWhere.push({
explicit: false
})
}
if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
replacements['userTagsSelected'] = user.permissions.itemTagsSelected
if (user.permissions.selectedTagsNotAccessible) {
bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
if (!accessAllTags && itemTagsSelected?.length) {
replacements['userTagsSelected'] = itemTagsSelected
if (selectedTagsNotAccessible) {
bookWhere.push(notExistsLiteral(`SELECT 1 FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)`))
} else {
bookWhere.push(
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
[Sequelize.Op.gte]: 1
})
)
bookWhere.push(existsLiteral(`SELECT 1 FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)`))
}
}
return {
@ -288,7 +324,7 @@ module.exports = {
} else if (value === 'ebook-in-progress') {
// Filters for ebook only
mediaWhere = [
Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0),
hasNoJsonValue('audioFiles'),
{
'$mediaProgresses.ebookProgress$': {
[Sequelize.Op.gt]: 0
@ -301,7 +337,7 @@ module.exports = {
} else if (value === 'ebook-finished') {
// Filters for ebook only
mediaWhere = [
Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0),
hasNoJsonValue('audioFiles'),
{
'$mediaProgresses.isFinished$': true,
ebookFile: {
@ -317,9 +353,7 @@ module.exports = {
} else if (group === 'explicit') {
mediaWhere['explicit'] = true
} else if (['genres', 'tags', 'narrators'].includes(group)) {
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
[Sequelize.Op.gte]: 1
})
mediaWhere = hasNamedBookValue(group)
replacements.filterValue = value
} else if (group === 'publishers') {
mediaWhere['publisher'] = value
@ -327,23 +361,17 @@ module.exports = {
mediaWhere['language'] = value
} else if (group === 'tracks') {
if (value === 'none') {
mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0)
mediaWhere = hasNoJsonValue('audioFiles')
} else if (value === 'multi') {
mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), {
[Sequelize.Op.gt]: 1
})
mediaWhere = hasAtLeastTwoJsonValues('audioFiles')
} else {
mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 1)
mediaWhere = hasExactlyOneJsonValue('audioFiles')
}
} else if (group === 'ebooks') {
if (value === 'ebook') {
mediaWhere['ebookFile'] = {
[Sequelize.Op.not]: null
}
mediaWhere = hasScalarValue('book.ebookFile')
} else if (value == 'no-ebook') {
mediaWhere['ebookFile'] = {
[Sequelize.Op.eq]: null
}
mediaWhere = hasNoScalarValue('book.ebookFile')
}
} else if (group === 'missing') {
if (['asin', 'isbn', 'subtitle', 'publishedYear', 'description', 'publisher', 'language', 'cover'].includes(value)) {
@ -353,9 +381,7 @@ module.exports = {
[Sequelize.Op.or]: [null, '']
}
} else if (['genres', 'tags', 'narrators', 'chapters'].includes(value)) {
mediaWhere[value] = {
[Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)]
}
mediaWhere = hasNoJsonValue(value)
} else if (value === 'authors') {
mediaWhere['$authors.id$'] = null
} else if (value === 'series') {

View file

@ -11,6 +11,8 @@ const libraryItemsBookFilters = require('../../../server/utils/queries/libraryIt
const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters')
describe('LibraryController large-library browse contract', () => {
const getLiteralSql = (predicate) => String(predicate?.attribute?.val || predicate?.attribute || predicate?.left?.val || predicate?.left || '')
beforeEach(async () => {
global.ServerSettings = {}
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
@ -382,4 +384,24 @@ describe('LibraryController large-library browse contract', () => {
expect(findAllStub.called).to.equal(false)
expect(countStub.called).to.equal(false)
})
it('builds EXISTS predicates for browse filters and permission-tag checks instead of correlated count subqueries', () => {
const { mediaWhere: tagWhere } = libraryItemsBookFilters.getMediaGroupQuery('tags', 'Fantasy')
const { mediaWhere: genreWhere } = libraryItemsBookFilters.getMediaGroupQuery('genres', 'Fantasy')
const { mediaWhere: narratorWhere } = libraryItemsBookFilters.getMediaGroupQuery('narrators', 'Jane Doe')
const { mediaWhere: trackWhere } = libraryItemsBookFilters.getMediaGroupQuery('tracks', 'multi')
const { mediaWhere: ebookWhere } = libraryItemsBookFilters.getMediaGroupQuery('ebooks', 'ebook')
const permissionWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery({
canAccessExplicitContent: true,
accessAllTags: false,
itemTagsSelected: ['Fantasy'],
selectedTagsNotAccessible: false
}).bookWhere[0]
;[tagWhere, genreWhere, narratorWhere, trackWhere, ebookWhere, permissionWhere].forEach((predicate) => {
const sql = getLiteralSql(predicate)
expect(sql).to.include('EXISTS')
expect(sql).to.not.include('count(*)')
})
})
})