diff --git a/packages/nql-lang/dist/parser.js b/packages/nql-lang/dist/parser.js index c20cabc7..c5a4ae48 100644 --- a/packages/nql-lang/dist/parser.js +++ b/packages/nql-lang/dist/parser.js @@ -169,10 +169,10 @@ case 24: this.$ = yy.relDateToAbsolute($$[$0-2], $$[$0-1], $$[$0]) break; case 25: - this.$ = yy.unescape($$[$0]); + this.$ = yy.normalizeAbsoluteDate(yy.unescape($$[$0])); break; case 26: - $$[$0] = $$[$0].replace(/^'|'$/g, ''); this.$ = yy.unescape($$[$0]); + $$[$0] = $$[$0].replace(/^'|'$/g, ''); this.$ = yy.normalizeAbsoluteDate(yy.unescape($$[$0])); break; case 27: this.$ = "add"; diff --git a/packages/nql-lang/lib/scope.js b/packages/nql-lang/lib/scope.js index 01f8f35c..cc1a3915 100644 --- a/packages/nql-lang/lib/scope.js +++ b/packages/nql-lang/lib/scope.js @@ -34,6 +34,20 @@ const formatDateForSQL = (date) => { return isoDate.replace('T', ' ').replace(/\.[0-9]{3}Z/, ''); }; +// A full ISO-8601 date-time: a date WITH a "T"-separated time component, +// optionally with fractional seconds and a timezone (Z or ±HH:MM). We +// deliberately require a time component so bare dates ("2025-02-27") and any +// other plain string are never rewritten — nql-lang has no column-type +// information, so this is the only shape we can safely normalize without +// risking a legitimate non-date value (e.g. a date-like slug). The "T" +// separator is required for the same reason: space-separated values are +// already in the stored format (or arbitrary text), and only the ISO "T" form +// exhibits the comparison bug being fixed. Hour/minute/second ranges are +// enforced so out-of-range times ("T24:00") can't slip through to `new Date`, +// which would roll them over to a different day instead of rejecting them. +// Groups: 1=date, 2=time (HH:mm[:ss]), 3=fraction, 4=zone. +const ISO_DATE_TIME = /^(\d{4}-\d{2}-\d{2})T((?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?)(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/; + module.exports = { ungroup(value) { return value.yg ? value.yg : value; @@ -71,6 +85,45 @@ module.exports = { return formatDateForSQL(relDate); }, + // Normalizes an absolute date-time value to the format dates are stored in + // ("YYYY-MM-DD HH:mm:ss", UTC) — the same format `relDateToAbsolute` + // produces for relative dates. This makes date comparisons behave + // identically on SQLite (where datetimes are text compared lexically, so a + // raw ISO "T" sorts after the stored space separator) and MySQL (which + // otherwise drops the timezone offset instead of applying it). A value that + // isn't a full ISO-8601 date-time, or that fails to parse, is returned + // untouched. + normalizeAbsoluteDate(value) { + // `preserveRelativeDates` opts in to a lossless parse (values survive + // for rendering/round-tripping), so absolute dates must survive + // untouched there too. + if (this.preserveRelativeDates || typeof value !== 'string') { + return value; + } + + const match = ISO_DATE_TIME.exec(value); + if (!match) { + return value; + } + + const [, date, time, fraction = '', zone] = match; + + // `new Date` rejects most out-of-range fields but silently rolls over + // days that are ≤31 yet invalid for their month (Feb 30 → Mar 1), which + // would make the filter query a different day than the user wrote. + const dayCheck = new Date(`${date}T00:00:00Z`); + if (Number.isNaN(dayCheck.getTime()) || dayCheck.toISOString().slice(0, 10) !== date) { + return value; + } + + // A zone-less value is interpreted as UTC (dates are stored in UTC), so + // we append "Z" rather than letting `new Date` treat it as local time. + const isoString = `${date}T${time}${fraction}${zone || 'Z'}`; + const parsed = new Date(isoString); + + return Number.isNaN(parsed.getTime()) ? value : formatDateForSQL(parsed); + }, + debug() { if (!process.env.DEBUG || !/nql/.test(process.env.DEBUG)) { return; diff --git a/packages/nql-lang/src/nql.y b/packages/nql-lang/src/nql.y index c8bbe644..47d2b811 100644 --- a/packages/nql-lang/src/nql.y +++ b/packages/nql-lang/src/nql.y @@ -82,8 +82,8 @@ VALUE | FALSE { $$ = false } | NUMBER { $$ = parseInt(yytext); } | NOW DATEOP AMOUNT INTERVAL { $$ = yy.relDateToAbsolute($2, $3, $4) } - | LITERAL { $$ = yy.unescape($1); } - | STRING { $1 = $1.replace(/^'|'$/g, ''); $$ = yy.unescape($1); } + | LITERAL { $$ = yy.normalizeAbsoluteDate(yy.unescape($1)); } + | STRING { $1 = $1.replace(/^'|'$/g, ''); $$ = yy.normalizeAbsoluteDate(yy.unescape($1)); } ; DATEOP diff --git a/packages/nql-lang/test/parser.test.js b/packages/nql-lang/test/parser.test.js index 6fc4f089..577ca698 100644 --- a/packages/nql-lang/test/parser.test.js +++ b/packages/nql-lang/test/parser.test.js @@ -709,6 +709,12 @@ describe('Parser', function () { }); }); + it('leaves absolute date-times untouched when enabled', function () { + parse('created_at:>=\'2025-02-27T19:03:00.000-05:00\'', {preserveRelativeDates: true}).should.eql({ + created_at: {$gte: '2025-02-27T19:03:00.000-05:00'} + }); + }); + it('preserves the same units the lexer recognises', function () { const intervals = [ ['d', 'days'], @@ -745,4 +751,50 @@ describe('Parser', function () { }); }); }); + + describe('Absolute dates', function () { + it('normalizes an ISO date-time with an offset to UTC db format', function () { + parse('published_at:>\'2025-02-27T19:03:00.000-05:00\'') + .should.eql({published_at: {$gt: '2025-02-28 00:03:00'}}); + }); + + it('normalizes a Zulu ISO date-time', function () { + parse('published_at:<\'2025-02-27T19:03:00Z\'') + .should.eql({published_at: {$lt: '2025-02-27 19:03:00'}}); + }); + + it('normalizes an equality date-time value', function () { + parse('created_at:\'2025-02-27T19:03:00.000Z\'') + .should.eql({created_at: '2025-02-27 19:03:00'}); + }); + + it('normalizes date-times inside an $in list', function () { + parse('published_at:[\'2025-02-27T19:03:00Z\',\'2025-03-01T00:00:00Z\']') + .should.eql({published_at: {$in: ['2025-02-27 19:03:00', '2025-03-01 00:00:00']}}); + }); + + it('normalizes date-times within a logical group', function () { + parse('featured:true+published_at:>\'2025-02-27T19:03:00-05:00\'') + .should.eql({$and: [{featured: true}, {published_at: {$gt: '2025-02-28 00:03:00'}}]}); + }); + + it('leaves a bare date untouched', function () { + parse('published_at:>\'2025-02-27\'') + .should.eql({published_at: {$gt: '2025-02-27'}}); + }); + + it('does not rewrite a non-date string value', function () { + parse('slug:\'2025-02-27\'').should.eql({slug: '2025-02-27'}); + }); + + it('does not rewrite a space-separated date-time (already in stored format)', function () { + parse('published_at:>\'2025-02-27 19:03:00\'') + .should.eql({published_at: {$gt: '2025-02-27 19:03:00'}}); + }); + + it('does not rewrite a calendar-invalid date-time', function () { + parse('published_at:>\'2025-02-30T00:00:00Z\'') + .should.eql({published_at: {$gt: '2025-02-30T00:00:00Z'}}); + }); + }); }); diff --git a/packages/nql-lang/test/scope.test.js b/packages/nql-lang/test/scope.test.js index a48a359f..bd02a10c 100644 --- a/packages/nql-lang/test/scope.test.js +++ b/packages/nql-lang/test/scope.test.js @@ -52,4 +52,91 @@ describe('Scope date helpers', function () { }); }); }); + + describe('normalizeAbsoluteDate', function () { + it('converts an ISO date-time with a timezone offset to UTC db format', function () { + scope.normalizeAbsoluteDate('2025-02-27T19:03:00.000-05:00') + .should.equal('2025-02-28 00:03:00'); + }); + + it('converts a Zulu ISO date-time to db format', function () { + scope.normalizeAbsoluteDate('2025-02-27T19:03:00Z') + .should.equal('2025-02-27 19:03:00'); + }); + + it('supports a "T" date-time without seconds', function () { + scope.normalizeAbsoluteDate('2025-02-27T19:03Z') + .should.equal('2025-02-27 19:03:00'); + }); + + it('truncates fractional seconds', function () { + scope.normalizeAbsoluteDate('2025-02-27T19:03:00.567Z') + .should.equal('2025-02-27 19:03:00'); + }); + + it('interprets a zone-less date-time as UTC', function () { + scope.normalizeAbsoluteDate('2025-02-27T19:03:00') + .should.equal('2025-02-27 19:03:00'); + }); + + it('is idempotent for a value already in db format', function () { + scope.normalizeAbsoluteDate('2025-02-27 19:03:00') + .should.equal('2025-02-27 19:03:00'); + }); + + it('leaves space-separated date-times untouched (only the ISO "T" form is rewritten)', function () { + scope.normalizeAbsoluteDate('2025-02-27 19:03') + .should.equal('2025-02-27 19:03'); + scope.normalizeAbsoluteDate('2025-02-27 19:03:00Z') + .should.equal('2025-02-27 19:03:00Z'); + }); + + it('leaves calendar-invalid dates untouched instead of rolling them over', function () { + // `new Date` would roll these to Mar 1 / May 1 rather than rejecting them + scope.normalizeAbsoluteDate('2025-02-30T00:00:00Z') + .should.equal('2025-02-30T00:00:00Z'); + scope.normalizeAbsoluteDate('2025-04-31T10:00:00Z') + .should.equal('2025-04-31T10:00:00Z'); + }); + + it('leaves out-of-range times untouched instead of rolling them over', function () { + // 24:00 is spec-legal for `new Date` and rolls to next-day midnight + scope.normalizeAbsoluteDate('2025-01-01T24:00:00Z') + .should.equal('2025-01-01T24:00:00Z'); + scope.normalizeAbsoluteDate('2025-01-01T19:60:00Z') + .should.equal('2025-01-01T19:60:00Z'); + }); + + it('leaves a bare date untouched (no time component)', function () { + scope.normalizeAbsoluteDate('2025-02-27').should.equal('2025-02-27'); + }); + + it('leaves a non-date string untouched', function () { + scope.normalizeAbsoluteDate('not-a-date').should.equal('not-a-date'); + }); + + it('leaves an unparseable date-time shaped string untouched', function () { + scope.normalizeAbsoluteDate('2025-13-45T99:99:99Z') + .should.equal('2025-13-45T99:99:99Z'); + }); + + it('returns non-string values unchanged', function () { + (scope.normalizeAbsoluteDate(null) === null).should.be.true(); + scope.normalizeAbsoluteDate(5).should.equal(5); + scope.normalizeAbsoluteDate(true).should.equal(true); + }); + + describe('with preserveRelativeDates flag set', function () { + afterEach(function () { + scope.preserveRelativeDates = false; + }); + + it('leaves absolute date-times untouched (lossless parse)', function () { + scope.preserveRelativeDates = true; + + scope.normalizeAbsoluteDate('2025-02-27T19:03:00.000-05:00') + .should.equal('2025-02-27T19:03:00.000-05:00'); + }); + }); + }); });