Skip to content

Commit 6c9f433

Browse files
committed
🐛 Fixed incorrect date filtering on SQLite
fixes #23441 - date columns are stored as "YYYY-MM-DD HH:MM:SS", but absolute date values in filters were passed through untouched while only relative dates (now-30d) were normalized - on SQLite, datetimes are stored as text and compared lexically, so the "T" in an ISO value sorts after the stored space separator and greater/less-than filters returned the wrong rows - normalizes date-column filter values to the stored UTC format before the query is built, leaving non-date columns and unparseable values untouched
1 parent d9e9681 commit 6c9f433

4 files changed

Lines changed: 308 additions & 0 deletions

File tree

ghost/core/core/server/models/base/bookshelf.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ ghostBookshelf.plugin(plugins.customQuery);
2020
// Load the Ghost filter plugin, which handles applying a 'filter' to findPage requests
2121
ghostBookshelf.plugin(plugins.filter);
2222

23+
// Normalize absolute date values in filters to the database date format, so date
24+
// comparisons behave consistently across SQLite and MySQL. Must come after the
25+
// filter plugin, which it wraps.
26+
ghostBookshelf.plugin(require('./plugins/date-filter'));
27+
2328
// Load the Ghost filter plugin, which handles applying a 'order' to findPage requests
2429
ghostBookshelf.plugin(plugins.order);
2530

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
const moment = require('moment');
2+
const {chainTransformers} = require('@tryghost/mongo-utils');
3+
const schemaTables = require('../../../data/schema/schema');
4+
5+
// Date columns are stored as "YYYY-MM-DD HH:MM:SS" (UTC). NQL normalizes relative
6+
// dates (e.g. `now-30d`) to that format, but absolute values from a filter
7+
// (e.g. `published_at:>'2025-02-27T19:03:00.000-05:00'`) are passed through as-is.
8+
// On SQLite, datetimes are stored as text and compared lexically, so the "T"
9+
// sorts after the space separator and the comparison returns the wrong rows.
10+
// We normalize those values to the stored format before the query is built.
11+
// See https://github.com/TryGhost/Ghost/issues/23441
12+
const ACCEPTED_DATE_FORMATS = [moment.ISO_8601, 'YYYY-MM-DD HH:mm:ss'];
13+
const DB_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
14+
15+
// Columns to treat as dates, keyed by column name. A name only qualifies when
16+
// it is a `dateTime` in every table that has it, so we never normalize a value
17+
// for a same-named column of another type. Filters resolve by column name (the
18+
// trailing segment), which keeps relation-qualified fields like `tags.created_at`
19+
// working without needing the model's table.
20+
let dateColumns = null;
21+
const getDateColumns = () => {
22+
if (!dateColumns) {
23+
const otherColumns = new Set();
24+
dateColumns = new Set();
25+
26+
for (const columns of Object.values(schemaTables)) {
27+
for (const [name, spec] of Object.entries(columns)) {
28+
if (spec && spec.type === 'dateTime') {
29+
dateColumns.add(name);
30+
} else if (spec && spec.type) {
31+
otherColumns.add(name);
32+
}
33+
}
34+
}
35+
36+
otherColumns.forEach(name => dateColumns.delete(name));
37+
}
38+
return dateColumns;
39+
};
40+
41+
const isDateColumn = (key) => {
42+
const column = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key;
43+
return getDateColumns().has(column);
44+
};
45+
46+
// Reformat a single value to the database date format. Non-strings and values we
47+
// can't parse as a date are returned untouched, so unexpected input is never
48+
// corrupted.
49+
const normalizeValue = (value) => {
50+
if (typeof value !== 'string') {
51+
return value;
52+
}
53+
54+
const parsed = moment.utc(value, ACCEPTED_DATE_FORMATS, true);
55+
return parsed.isValid() ? parsed.format(DB_DATE_FORMAT) : value;
56+
};
57+
58+
// An operator map like `{$gt: ...}`: a plain object whose keys are all operators.
59+
// Anything else that happens to be an object (e.g. a `Date`) is not one and must
60+
// be left untouched rather than reduced to `{}`.
61+
const isOperatorMap = (value) => {
62+
if (!value || Object.prototype.toString.call(value) !== '[object Object]') {
63+
return false;
64+
}
65+
66+
const keys = Object.keys(value);
67+
return keys.length > 0 && keys.every(key => key.charAt(0) === '$');
68+
};
69+
70+
// A field value is a plain value (equality), an array (e.g. `$in`), or an operator
71+
// map (e.g. `{$gt: ...}`).
72+
const normalizeFieldValue = (value) => {
73+
if (Array.isArray(value)) {
74+
return value.map(normalizeValue);
75+
}
76+
77+
if (isOperatorMap(value)) {
78+
const result = {};
79+
for (const [operator, operatorValue] of Object.entries(value)) {
80+
result[operator] = Array.isArray(operatorValue)
81+
? operatorValue.map(normalizeValue)
82+
: normalizeValue(operatorValue);
83+
}
84+
return result;
85+
}
86+
87+
return normalizeValue(value);
88+
};
89+
90+
// Walk the parsed mongo-JSON filter, normalizing date column values and recursing
91+
// into `$and`/`$or` groups.
92+
const normalizeDateFilters = (node) => {
93+
if (Array.isArray(node)) {
94+
return node.map(normalizeDateFilters);
95+
}
96+
97+
if (!node || typeof node !== 'object') {
98+
return node;
99+
}
100+
101+
const result = {};
102+
for (const [key, value] of Object.entries(node)) {
103+
if (key.charAt(0) === '$') {
104+
result[key] = normalizeDateFilters(value);
105+
} else if (isDateColumn(key)) {
106+
result[key] = normalizeFieldValue(value);
107+
} else {
108+
result[key] = value;
109+
}
110+
}
111+
return result;
112+
};
113+
114+
/**
115+
* Normalizes absolute date values in NQL filters to the database date format, so
116+
* date comparisons behave the same on SQLite and MySQL. Wraps
117+
* `applyDefaultAndCustomFilters` and chains the date transformer after any
118+
* transformer the caller supplied.
119+
*
120+
* @param {import('bookshelf')} Bookshelf
121+
*/
122+
module.exports = function (Bookshelf) {
123+
const parentApply = Bookshelf.Model.prototype.applyDefaultAndCustomFilters;
124+
125+
Bookshelf.Model = Bookshelf.Model.extend({
126+
applyDefaultAndCustomFilters: function applyDefaultAndCustomFilters(options = {}) {
127+
const mongoTransformer = options.mongoTransformer
128+
? chainTransformers(options.mongoTransformer, normalizeDateFilters)
129+
: normalizeDateFilters;
130+
131+
return parentApply.call(this, Object.assign({}, options, {mongoTransformer}));
132+
}
133+
});
134+
};
135+
136+
module.exports.normalizeDateFilters = normalizeDateFilters;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const assert = require('node:assert/strict');
2+
const testUtils = require('../../utils');
3+
const models = require('../../../core/server/models');
4+
5+
const context = testUtils.context.owner;
6+
const markdownToMobiledoc = testUtils.DataGenerator.markdownToMobiledoc;
7+
8+
// Regression test for https://github.com/TryGhost/Ghost/issues/23441
9+
// On SQLite an absolute ISO date in a filter used to sort after the stored
10+
// "YYYY-MM-DD HH:MM:SS" format (because "T" > " "), returning the wrong rows.
11+
describe('Integration: Post date filtering', function () {
12+
const early = new Date(Date.UTC(2025, 5, 15, 9, 0, 0)); // same day, before the boundary
13+
const late = new Date(Date.UTC(2025, 5, 15, 12, 0, 0)); // same day, after the boundary
14+
const later = new Date(Date.UTC(2025, 11, 31, 23, 0, 0)); // a later day
15+
16+
const addPost = (title, publishedAt) => models.Post.add({
17+
status: 'published',
18+
title,
19+
published_at: publishedAt,
20+
mobiledoc: markdownToMobiledoc('content')
21+
}, context);
22+
23+
beforeAll(testUtils.teardownDb);
24+
beforeAll(testUtils.setup('users:roles'));
25+
beforeAll(async function () {
26+
await addPost('early-same-day', early);
27+
await addPost('late-same-day', late);
28+
await addPost('later-day', later);
29+
});
30+
afterAll(testUtils.teardownDb);
31+
32+
const titlesFor = async (filter) => {
33+
const result = await models.Post.findPage({filter, status: 'all'});
34+
return result.data.map(post => post.get('title')).sort();
35+
};
36+
37+
it('includes a same-day post that is after a "greater than" ISO boundary', async function () {
38+
// Boundary is 2025-06-15 10:00:00 UTC. "late-same-day" (12:00) is after it.
39+
const titles = await titlesFor("published_at:>'2025-06-15T10:00:00.000Z'");
40+
41+
assert.deepEqual(titles, ['late-same-day', 'later-day']);
42+
});
43+
44+
it('excludes a same-day post that is before a "less than" ISO boundary', async function () {
45+
// Boundary is 2025-06-15 10:00:00 UTC. Only "early-same-day" (09:00) is before it.
46+
const titles = await titlesFor("published_at:<'2025-06-15T10:00:00.000Z'");
47+
48+
assert.deepEqual(titles, ['early-same-day']);
49+
});
50+
51+
it('handles an ISO boundary with a timezone offset', async function () {
52+
// 2025-06-15T05:00:00-05:00 is 2025-06-15 10:00:00 UTC, same boundary as above.
53+
const titles = await titlesFor("published_at:>'2025-06-15T05:00:00.000-05:00'");
54+
55+
assert.deepEqual(titles, ['late-same-day', 'later-day']);
56+
});
57+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
const assert = require('node:assert/strict');
2+
const {normalizeDateFilters} = require('../../../../../core/server/models/base/plugins/date-filter');
3+
4+
describe('Models: date-filter', function () {
5+
describe('normalizeDateFilters', function () {
6+
it('normalizes an ISO date with a timezone offset on a date column to UTC db format', function () {
7+
const result = normalizeDateFilters({
8+
published_at: {$gt: '2025-02-27T19:03:00.000-05:00'}
9+
});
10+
11+
assert.deepEqual(result, {
12+
published_at: {$gt: '2025-02-28 00:03:00'}
13+
});
14+
});
15+
16+
it('normalizes a Zulu ISO date on a date column', function () {
17+
const result = normalizeDateFilters({
18+
published_at: {$lt: '2025-02-27T19:03:00Z'}
19+
});
20+
21+
assert.deepEqual(result, {
22+
published_at: {$lt: '2025-02-27 19:03:00'}
23+
});
24+
});
25+
26+
it('normalizes an equality value on a date column', function () {
27+
const result = normalizeDateFilters({
28+
created_at: '2025-02-27T19:03:00.000Z'
29+
});
30+
31+
assert.deepEqual(result, {
32+
created_at: '2025-02-27 19:03:00'
33+
});
34+
});
35+
36+
it('leaves values already in db format untouched', function () {
37+
const filter = {published_at: {$gt: '2025-02-27 19:03:00'}};
38+
39+
assert.deepEqual(normalizeDateFilters(filter), {
40+
published_at: {$gt: '2025-02-27 19:03:00'}
41+
});
42+
});
43+
44+
it('does not touch non-date columns even when the value looks like a date', function () {
45+
const filter = {slug: '2025-02-27', title: {$ne: '2025-02-27T19:03:00Z'}};
46+
47+
assert.deepEqual(normalizeDateFilters(filter), filter);
48+
});
49+
50+
it('leaves unparseable values on a date column untouched', function () {
51+
const filter = {published_at: {$gt: 'not-a-date'}};
52+
53+
assert.deepEqual(normalizeDateFilters(filter), filter);
54+
});
55+
56+
it('leaves a non-plain object value (e.g. a Date) on a date column untouched', function () {
57+
const date = new Date('2025-02-27T19:03:00Z');
58+
const result = normalizeDateFilters({published_at: date});
59+
60+
assert.equal(result.published_at, date);
61+
});
62+
63+
it('normalizes arrays of values ($in)', function () {
64+
const result = normalizeDateFilters({
65+
published_at: {$in: ['2025-02-27T19:03:00Z', '2025-03-01T00:00:00Z']}
66+
});
67+
68+
assert.deepEqual(result, {
69+
published_at: {$in: ['2025-02-27 19:03:00', '2025-03-01 00:00:00']}
70+
});
71+
});
72+
73+
it('recurses into $and / $or groups', function () {
74+
const result = normalizeDateFilters({
75+
$and: [
76+
{published_at: {$gt: '2025-02-27T19:03:00Z'}},
77+
{$or: [
78+
{featured: true},
79+
{updated_at: {$lt: '2025-03-01T00:00:00Z'}}
80+
]}
81+
]
82+
});
83+
84+
assert.deepEqual(result, {
85+
$and: [
86+
{published_at: {$gt: '2025-02-27 19:03:00'}},
87+
{$or: [
88+
{featured: true},
89+
{updated_at: {$lt: '2025-03-01 00:00:00'}}
90+
]}
91+
]
92+
});
93+
});
94+
95+
it('resolves relation-qualified date columns by column name', function () {
96+
const result = normalizeDateFilters({
97+
'posts.published_at': {$gt: '2025-02-27T19:03:00Z'}
98+
});
99+
100+
assert.deepEqual(result, {
101+
'posts.published_at': {$gt: '2025-02-27 19:03:00'}
102+
});
103+
});
104+
105+
it('returns primitive nodes unchanged', function () {
106+
assert.equal(normalizeDateFilters(null), null);
107+
assert.equal(normalizeDateFilters('string'), 'string');
108+
});
109+
});
110+
});

0 commit comments

Comments
 (0)