Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/nql-lang/dist/parser.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions packages/nql-lang/lib/scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/nql-lang/src/nql.y
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions packages/nql-lang/test/parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'}});
});
});
});
87 changes: 87 additions & 0 deletions packages/nql-lang/test/scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});