guard numeric casts for postgres text fields

This commit is contained in:
Kevin Gatera 2026-03-02 18:20:58 -05:00
parent 15d2715a74
commit 8b3f67b7c9
No known key found for this signature in database
GPG key ID: F0D9F5932458CFB9
4 changed files with 42 additions and 25 deletions

View file

@ -1,6 +1,7 @@
const { DataTypes, Model, where, fn, col, literal } = require('sequelize') const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
const { safeTextToDoubleExpression } = require('../utils/sqlDialectHelpers')
class Series extends Model { class Series extends Model {
constructor(values, options) { constructor(values, options) {
@ -158,7 +159,7 @@ class Series extends Model {
} }
} }
], ],
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]] order: [[literal(`${safeTextToDoubleExpression(this.sequelize.getDialect() === 'postgres' ? '"bookSeries"."sequence"' : '`bookSeries.sequence`', this.sequelize)} ASC NULLS LAST`)]]
}) })
} }

View file

@ -6,7 +6,7 @@ const authorFilters = require('./authorFilters')
const ShareManager = require('../../managers/ShareManager') const ShareManager = require('../../managers/ShareManager')
const { profile } = require('../profiler') const { profile } = require('../profiler')
const stringifySequelizeQuery = require('../stringifySequelizeQuery') const stringifySequelizeQuery = require('../stringifySequelizeQuery')
const { booleanLiteral, noCaseSortExpression, coalesceFunctionName, jsonArrayContainsAny, jsonArrayContainsValue, jsonArrayExpand } = require('../sqlDialectHelpers') const { booleanLiteral, noCaseSortExpression, coalesceFunctionName, jsonArrayContainsAny, jsonArrayContainsValue, jsonArrayExpand, safeTextToDoubleExpression, safeTextToIntegerExpression } = require('../sqlDialectHelpers')
const countCache = new Map() const countCache = new Map()
module.exports = { module.exports = {
@ -237,7 +237,7 @@ module.exports = {
} else if (group === 'publishedDecades') { } else if (group === 'publishedDecades') {
const startYear = parseInt(value) const startYear = parseInt(value)
const endYear = parseInt(value, 10) + 9 const endYear = parseInt(value, 10) + 9
mediaWhere = Sequelize.where(Sequelize.literal('CAST(publishedYear AS INTEGER)'), { mediaWhere = Sequelize.where(Sequelize.literal(safeTextToIntegerExpression('publishedYear', Database.sequelize)), {
[Sequelize.Op.between]: [startYear, endYear] [Sequelize.Op.between]: [startYear, endYear]
}) })
} }
@ -274,7 +274,7 @@ module.exports = {
} else if (sortBy === 'media.duration') { } else if (sortBy === 'media.duration') {
return [['duration', dir]] return [['duration', dir]]
} else if (sortBy === 'media.metadata.publishedYear') { } else if (sortBy === 'media.metadata.publishedYear') {
return [[Sequelize.literal('CAST(book.publishedYear AS INTEGER)'), dir]] return [[Sequelize.literal(safeTextToIntegerExpression('book.publishedYear', Database.sequelize)), dir]]
} else if (sortBy === 'media.metadata.authorNameLF') { } else if (sortBy === 'media.metadata.authorNameLF') {
// Sort by author name last first, secondary sort by title // Sort by author name last first, secondary sort by title
return [[Sequelize.literal(noCaseSortExpression('libraryItem.authorNamesLastFirst', Database.sequelize)), dir], getTitleOrder()] return [[Sequelize.literal(noCaseSortExpression('libraryItem.authorNamesLastFirst', Database.sequelize)), dir], getTitleOrder()]
@ -288,10 +288,8 @@ module.exports = {
return [getTitleOrder()] return [getTitleOrder()]
} else if (sortBy === 'sequence') { } else if (sortBy === 'sequence') {
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
if (Database.sequelize.getDialect() === 'postgres') { const sequenceColumn = Database.sequelize.getDialect() === 'postgres' ? '"series.bookSeries"."sequence"' : '`series.bookSeries.sequence`'
return [[Sequelize.literal(`CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ${nullDir}`)]] return [[Sequelize.literal(`${safeTextToDoubleExpression(sequenceColumn, Database.sequelize)} ${nullDir}`)]]
}
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
} else if (sortBy === 'progress') { } else if (sortBy === 'progress') {
return [[Sequelize.literal(`mediaProgresses.updatedAt ${dir} NULLS LAST`)]] return [[Sequelize.literal(`mediaProgresses.updatedAt ${dir} NULLS LAST`)]]
} else if (sortBy === 'progress.createdAt') { } else if (sortBy === 'progress.createdAt') {
@ -331,11 +329,7 @@ module.exports = {
} }
], ],
order: [ order: [
Sequelize.literal( Sequelize.literal(`${safeTextToDoubleExpression(Database.sequelize.getDialect() === 'postgres' ? '"books.bookSeries"."sequence"' : '`books.bookSeries.sequence`', Database.sequelize)} ASC NULLS LAST`)
Database.sequelize.getDialect() === 'postgres'
? 'CAST("books.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST'
: 'CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST'
)
] ]
}) })
const bookSeriesToInclude = [] const bookSeriesToInclude = []
@ -529,11 +523,7 @@ module.exports = {
if (sortBy !== 'sequence') { if (sortBy !== 'sequence') {
// Secondary sort by sequence // Secondary sort by sequence
sortOrder.push([ sortOrder.push([
Sequelize.literal( Sequelize.literal(`${safeTextToDoubleExpression(Database.sequelize.getDialect() === 'postgres' ? '"series.bookSeries"."sequence"' : '`series.bookSeries.sequence`', Database.sequelize)} ASC NULLS LAST`)
Database.sequelize.getDialect() === 'postgres'
? 'CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST'
: 'CAST(`series.bookSeries.sequence` AS FLOAT) ASC NULLS LAST'
)
]) ])
} }
} else if (filterGroup === 'issues') { } else if (filterGroup === 'issues') {
@ -755,11 +745,11 @@ module.exports = {
let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = ${booleanLiteral(false, Database.sequelize)} OR mp.isFinished IS NULL)` let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = ${booleanLiteral(false, Database.sequelize)} OR mp.isFinished IS NULL)`
if (library.settings.onlyShowLaterBooksInContinueSeries) { if (library.settings.onlyShowLaterBooksInContinueSeries) {
const castType = Database.sequelize.getDialect() === 'postgres' ? 'DOUBLE PRECISION' : 'FLOAT' const safeSequenceExpr = safeTextToDoubleExpression('bs.sequence', Database.sequelize)
const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as ${castType}) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)} AND mp.userId = :userId AND bs.seriesId = series.id)` const maxSequenceQuery = `(SELECT max(${safeSequenceExpr}) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)} AND mp.userId = :userId AND bs.seriesId = series.id)`
includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence']) includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence'])
booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as ${castType}) > ${maxSequenceQuery}` booksNotFinishedQuery = booksNotFinishedQuery + ` AND ${safeSequenceExpr} > ${maxSequenceQuery}`
} }
const { rows: series, count } = await Database.seriesModel.findAndCountAll({ const { rows: series, count } = await Database.seriesModel.findAndCountAll({
@ -794,7 +784,7 @@ module.exports = {
attributes: ['bookId', 'sequence'], attributes: ['bookId', 'sequence'],
separate: true, separate: true,
subQuery: false, subQuery: false,
order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]], order: [[Sequelize.literal(`${safeTextToDoubleExpression('sequence', Database.sequelize)} ASC NULLS LAST`)]],
where: { where: {
'$book.mediaProgresses.isFinished$': { '$book.mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, false] [Sequelize.Op.or]: [null, false]
@ -907,7 +897,7 @@ module.exports = {
model: Database.bookModel, model: Database.bookModel,
where: userPermissionBookWhere.bookWhere where: userPermissionBookWhere.bookWhere
}, },
order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]], order: [[Sequelize.literal(`${safeTextToDoubleExpression('sequence', Database.sequelize)} ASC NULLS LAST`)]],
limit: 1 limit: 1
}, },
subQuery: false, subQuery: false,

View file

@ -61,6 +61,20 @@ function jsonPathNumber(columnExpression, pathSegments, sequelize) {
return `json_extract(${columnExpression}, '$.${[].concat(pathSegments).join('.')}')` return `json_extract(${columnExpression}, '$.${[].concat(pathSegments).join('.')}')`
} }
function safeTextToDoubleExpression(columnExpression, sequelize) {
if (isPostgres(sequelize)) {
return `CASE WHEN BTRIM(${columnExpression}) ~ '^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)$' THEN BTRIM(${columnExpression})::double precision ELSE NULL END`
}
return `CAST(${columnExpression} AS FLOAT)`
}
function safeTextToIntegerExpression(columnExpression, sequelize) {
if (isPostgres(sequelize)) {
return `CASE WHEN BTRIM(${columnExpression}) ~ '^[+-]?\\d+$' THEN BTRIM(${columnExpression})::integer ELSE NULL END`
}
return `CAST(${columnExpression} AS INTEGER)`
}
module.exports = { module.exports = {
getDialect, getDialect,
isPostgres, isPostgres,
@ -71,5 +85,7 @@ module.exports = {
jsonArrayContainsValue, jsonArrayContainsValue,
jsonArrayExpand, jsonArrayExpand,
jsonPathText, jsonPathText,
jsonPathNumber jsonPathNumber,
safeTextToDoubleExpression,
safeTextToIntegerExpression
} }

View file

@ -8,7 +8,9 @@ const {
jsonArrayContainsValue, jsonArrayContainsValue,
jsonArrayExpand, jsonArrayExpand,
jsonPathText, jsonPathText,
jsonPathNumber jsonPathNumber,
safeTextToDoubleExpression,
safeTextToIntegerExpression
} = require('../../../server/utils/sqlDialectHelpers') } = require('../../../server/utils/sqlDialectHelpers')
const sqlite = { const sqlite = {
@ -55,4 +57,12 @@ describe('sqlDialectHelpers', () => {
expect(jsonPathNumber('payload', ['duration'], sqlite)).to.equal("json_extract(payload, '$.duration')") expect(jsonPathNumber('payload', ['duration'], sqlite)).to.equal("json_extract(payload, '$.duration')")
expect(jsonPathNumber('payload', ['duration'], postgres)).to.equal("NULLIF(payload::jsonb #>> '{duration}', '')::double precision") expect(jsonPathNumber('payload', ['duration'], postgres)).to.equal("NULLIF(payload::jsonb #>> '{duration}', '')::double precision")
}) })
it('should generate safe numeric cast expressions for sequence-like text', () => {
expect(safeTextToDoubleExpression('sequence', sqlite)).to.equal('CAST(sequence AS FLOAT)')
expect(safeTextToDoubleExpression('"bookSeries"."sequence"', postgres)).to.equal("CASE WHEN BTRIM(\"bookSeries\".\"sequence\") ~ '^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)$' THEN BTRIM(\"bookSeries\".\"sequence\")::double precision ELSE NULL END")
expect(safeTextToIntegerExpression('publishedYear', sqlite)).to.equal('CAST(publishedYear AS INTEGER)')
expect(safeTextToIntegerExpression('book.publishedYear', postgres)).to.equal("CASE WHEN BTRIM(book.publishedYear) ~ '^[+-]?\\d+$' THEN BTRIM(book.publishedYear)::integer ELSE NULL END")
})
}) })