mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
guard numeric casts for postgres text fields
This commit is contained in:
parent
15d2715a74
commit
8b3f67b7c9
4 changed files with 42 additions and 25 deletions
|
|
@ -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`)]]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue