Skip to content

Commit d5cd6a6

Browse files
committed
improve search parameter when working with dates
1 parent 1f3fa8a commit d5cd6a6

File tree

9 files changed

+162
-21
lines changed

9 files changed

+162
-21
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import { toSearchDateDslString } from './OpensearchDatetimeUtils';
3+
import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime';
4+
5+
6+
describe('OpensearchDatetimeUtils.toSearchDateDslString()', () => {
7+
const testcases = [
8+
{ input: '2025-03-01T12:34:56Z', expected: '2025-03-01T12:34:56Z' },
9+
{ input: '2025-03-01', expected: '2025-03-01||/d' },
10+
{ input: '2025-03', expected: '2025-03||/M' },
11+
{ input: '2025', expected: '2025||/y' },
12+
];
13+
14+
testcases.forEach(({ input, expected }) => {
15+
test(`throws for "${input}"`, () => {
16+
let iso = (input.length > 10) ? IsoDatetime.parse(input) : IsoDate.parse(input);
17+
expect(toSearchDateDslString(iso)).toBe(expected);
18+
});
19+
});
20+
21+
test('throws on invalid date string', () => {
22+
expect(() => IsoDate.parse('2025-13')).toThrow();
23+
});
24+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* OpenSearch and ElasticSearch both use additional symbols in the Search DSL to make ISO 8601 representations
3+
* even more precise for years, months, days. This utility class simplifies that
4+
*/
5+
6+
import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime.js';
7+
8+
export const toSearchDateDslString = (iso: IsoDate | IsoDatetime): string => {
9+
if (iso.isMonth()) {
10+
// isoDate is a month
11+
return `${iso.toString()}||/M`;
12+
}
13+
else if (iso.isYear()) {
14+
// isoDate is a year
15+
return `${iso.toString()}||/y`;
16+
}
17+
else if (iso.isDate()) {
18+
// isoDate is a year
19+
return `${iso.toString()}||/d`;
20+
}
21+
else {
22+
// all other instances will be the original string
23+
return iso.toString();
24+
}
25+
};

src/common/IsoDate/IsoDate.test.unit.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ describe('IsoDate.parse – valid inputs', () => {
2323
expect(d.month).toBe(1);
2424
expect(d.day).toBe(1);
2525
expect(d.toString()).toBe('2025-01-01');
26+
expect(d.isDate()).toBeTruthy();
27+
expect(d.isMonth()).toBeFalsy();
28+
expect(d.isYear()).toBeFalsy()
2629
});
2730

2831
test('year‑month', () => {
@@ -33,6 +36,9 @@ describe('IsoDate.parse – valid inputs', () => {
3336
expect(d.month).toBe(1);
3437
expect(d.day).toBeUndefined();
3538
expect(d.toString()).toBe('2025-01');
39+
expect(d.isDate()).toBeFalsy();
40+
expect(d.isMonth()).toBeTruthy();
41+
expect(d.isYear()).toBeFalsy()
3642
});
3743

3844
test('year only', () => {
@@ -43,6 +49,9 @@ describe('IsoDate.parse – valid inputs', () => {
4349
expect(d.month).toBeUndefined();
4450
expect(d.day).toBeUndefined();
4551
expect(d.toString()).toBe('2025');
52+
expect(d.isDate()).toBeFalsy();
53+
expect(d.isMonth()).toBeFalsy();
54+
expect(d.isYear()).toBeTruthy()
4655
});
4756

4857
test('leap‑year February 29', () => {

src/common/IsoDate/IsoDate.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
22
* IsoDate – a lightweight class for representing calendar dates.
3-
*
3+
*
44
* Supported input formats:
5-
* - YYYY-MM-DD (e.g., 2025-01-01)
6-
* - YYYY-MM (e.g., 2025-01)
7-
* - YYYY (e.g., 2025)
5+
* - YYYY-MM-DD (e.g., 2025-01-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-01T23:59:59.999Z)
6+
* - YYYY-MM (e.g., 2025-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-31T23:59:59.999Z)
7+
* - YYYY (e.g., 2025, equivalent to 2025-01-01T00:00:00.000Z/2025-12-31T23:59:59.999Z)
8+
*
9+
* As shown in the example above, all IsoDate is assumed to be UTC (i.e., timezone = 'Z')
810
*
911
* Note we do not support (even though it is allowed by ISO 8601)
1012
* - "compact date" (i.e., YYYYMMDD)
@@ -111,6 +113,22 @@ export class IsoDate {
111113
return IsoDate.isLeapYear(this.year);
112114
}
113115

116+
/** Return true if the stored year is a leap year. */
117+
public isYear(): boolean {
118+
return this.toString().length === 4;
119+
}
120+
121+
/** Return true if the stored year is a leap year. */
122+
public isMonth(): boolean {
123+
return this.toString().length === 7;
124+
}
125+
126+
/** Return true if the stored year is a leap year. */
127+
public isDate(): boolean {
128+
return this.toString().length === 10;
129+
}
130+
131+
114132
/** Normalized string representation. */
115133
public toString(): string {
116134
const y = String(this.year).padStart(4, '0');

src/common/IsoDate/IsoDatetime.test.unit.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
/**
2+
* Unit tests for IsoDatetime
3+
*
4+
* AI Usage:
5+
* - This code was originally generated using
6+
* 1. OpenAI/GPT OSS 120b on Roo Code
7+
* 2. Gemini 2.5 Flash and 2.5 Pro
8+
* then modified to fix incorrect implementations and fit project needs.
9+
* The first commit contains these corrections so that all code committed
10+
* works as designed.
11+
*/
112
import { describe, test, expect } from '@jest/globals';
213
import { IsoDatetime } from './IsoDatetime.js';
314

@@ -60,6 +71,9 @@ describe('IsoDatetime.parse – valid inputs', () => {
6071

6172
describe('IsoDatetime.parse – invalid inputs', () => {
6273
const invalid = [
74+
'2025-03-01', // date only
75+
'2025-03', // month only
76+
'2025', // year only
6377
'2025-03-01 12:34:56Z', // no 'T' separator
6478
'2025-03-01T12:34Z', // missing seconds
6579
'2025-03-01T12:34:56.789', // missing Z
@@ -97,54 +111,76 @@ describe('IsoDatetime.toString – formatting', () => {
97111
});
98112
});
99113

114+
115+
describe('IsoDatetime.toIsoDate', () => {
116+
const tests = [
117+
{ input: '2025-03-01T12:34:56Z', expected: '2025-03-01' },
118+
{ input: '2025-03-01T12:34:56.001Z', expected: '2025-03-01' },
119+
{ input: '2025-03-01T12:34:56+02:00', expected: '2025-03-01' },
120+
{ input: '2025-03-01T12:34:56-04:30', expected: '2025-03-01' },
121+
];
122+
123+
tests.forEach(({ input, expected }) => {
124+
test(`converts an IsoDatetime(${input}) to its IsoDate equivalent`, () => {
125+
const isoDatetime = IsoDatetime.parse(input);
126+
const isoDate = isoDatetime.toIsoDate();
127+
expect(isoDate.toString()).toBe(expected);
128+
});
129+
});
130+
});
131+
100132
describe('IsoDatetime.getNextDay – day increments and decrements', () => {
133+
test('regular date: March 4 +1 day => March 5', () => {
134+
const dt = IsoDatetime.parse('2024-03-04T00:00:00Z');
135+
expect(dt.getNextDay()).toEqual(IsoDatetime.parse('2024-03-05T00:00:00Z'));
136+
});
101137
test('leap year: Feb 28 +1 day => Feb 29', () => {
102138
const dt = IsoDatetime.parse('2024-02-28T00:00:00Z');
103-
expect(dt.getNextDay()).toBe('2024-02-29T00:00:00Z');
139+
expect(dt.getNextDay()).toEqual(IsoDatetime.parse('2024-02-29T00:00:00Z'));
104140
});
105141

106142
test('non‑leap year: Feb 28 +1 day => Mar 01', () => {
107143
const dt = IsoDatetime.parse('2025-02-28T00:00:00Z');
108-
expect(dt.getNextDay(1)).toBe('2025-03-01T00:00:00Z');
144+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2025-03-01T00:00:00Z'));
109145
});
110146

111147
test('leap day: Feb 29 +1 day => Mar 01', () => {
112148
const dt = IsoDatetime.parse('2024-02-29T00:00:00Z');
113-
expect(dt.getNextDay(1)).toBe('2024-03-01T00:00:00Z');
149+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2024-03-01T00:00:00Z'));
114150
});
115151

116152
test('month boundary: Jan 31 +1 day => Feb 01', () => {
117153
const dt = IsoDatetime.parse('2025-01-31T00:00:00Z');
118-
expect(dt.getNextDay(1)).toBe('2025-02-01T00:00:00Z');
154+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2025-02-01T00:00:00Z'));
119155
});
120156

121157
test('year boundary: Dec 31 +1 day => Jan 01 of next year', () => {
122158
const dt = IsoDatetime.parse('2025-12-31T00:00:00Z');
123-
expect(dt.getNextDay(1)).toBe('2026-01-01T00:00:00Z');
159+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2026-01-01T00:00:00Z'));
124160
});
125161

126162
test('century non‑leap year (1900): Feb 28 +1 day => Mar 01', () => {
127163
const dt = IsoDatetime.parse('1900-02-28T00:00:00Z');
128-
expect(dt.getNextDay(1)).toBe('1900-03-01T00:00:00Z');
164+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('1900-03-01T00:00:00Z'));
129165
});
130166

131167
test('century leap year (2000): Feb 28 +1 day => Feb 29', () => {
132168
const dt = IsoDatetime.parse('2000-02-28T00:00:00Z');
133-
expect(dt.getNextDay(1)).toBe('2000-02-29T00:00:00Z');
169+
expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2000-02-29T00:00:00Z'));
134170
});
135171

136172
test('negative increment: Mar 01 -1 day => Feb 28 (non‑leap year)', () => {
137173
const dt = IsoDatetime.parse('2025-03-01T00:00:00Z');
138-
expect(dt.getNextDay(-1)).toBe('2025-02-28T00:00:00Z');
174+
expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2025-02-28T00:00:00Z'));
139175
});
140176

141177
test('negative increment: Mar 01 -1 day => Feb 28 (leap year)', () => {
142178
const dt = IsoDatetime.parse('2024-03-01T00:00:00Z');
143-
expect(dt.getNextDay(-1)).toBe('2024-02-29T00:00:00Z');
179+
expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2024-02-29T00:00:00Z'));
144180
});
145181

146182
test('negative crossing year: Jan 01 -1 day => Dec 31 of previous year', () => {
147183
const dt = IsoDatetime.parse('2025-01-01T00:00:00Z');
148-
expect(dt.getNextDay(-1)).toBe('2024-12-31T00:00:00Z');
184+
expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2024-12-31T00:00:00Z'));
149185
});
150-
});
186+
});

src/common/IsoDate/IsoDatetime.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,19 @@
1515
* Example:
1616
* const dt = IsoDatetime.parse('2025-03-01T12:34:56.789+02:00');
1717
* console.log(dt.toString()); // "2025-03-01T10:34:56.789Z"
18+
*
19+
* AI Usage:
20+
* - This code was originally generated using
21+
* 1. OpenAI/GPT OSS 120b on Roo Code
22+
* 2. Gemini 2.5 Flash and 2.5 Pro
23+
* then modified to fix incorrect implementations and fit project needs.
24+
* The first commit contains these corrections so that all code committed
25+
* works as designed.
1826
*/
1927
import { IsoDate } from './IsoDate.js';
2028

29+
export * from './IsoDate.js'
30+
2131
export class IsoDatetime extends IsoDate {
2232
/** Hour (0‑23) */
2333
public readonly hour: number;
@@ -151,14 +161,28 @@ export class IsoDatetime extends IsoDate {
151161
const msPart = this.millisecond > 0 ? `.${String(this.millisecond).padStart(3, '0')}` : '';
152162
return `${y}-${m}-${d}T${h}:${min}:${s}${msPart}Z`;
153163
}
164+
165+
/** returns an IsoDate
166+
* Note: lossy precision
167+
* Even though this reduces the precision of a Datetime, it is useful for some purposes
168+
*/
169+
public toIsoDate(): IsoDate {
170+
const y = String(this.year).padStart(4, '0');
171+
const m = String(this.month).padStart(2, '0');
172+
const d = String(this.day).padStart(2, '0');
173+
const dateString = `${y}-${m}-${d}`;
174+
return IsoDate.parse(dateString);
175+
}
176+
177+
154178
/**
155179
* Returns an ISO‑8601 string representing this datetime shifted by the given
156180
* number of days (positive or negative). The shift respects month lengths,
157181
* leap‑year rules, and century leap‑year exceptions.
158182
*
159183
* @param increment Number of days to shift; may be negative.
160184
*/
161-
public getNextDay(increment: number = 1): string {
185+
public getNextDay(increment: number = 1): IsoDatetime {
162186
// Compute the UTC timestamp for the current instance.
163187
const utcMs = Date.UTC(
164188
this.year,
@@ -181,7 +205,7 @@ export class IsoDatetime extends IsoDate {
181205
d.getUTCSeconds(),
182206
d.getUTCMilliseconds(),
183207
);
184-
return next.toString();
208+
return next;
185209
}
186210

187211
}

src/search/SearchQueryBuilder.test.unit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// For a more comprehensive set of test cases, see the tests
22
// in test_cases/search_*
3-
import { describe, it, test, expect } from '@jest/globals';
3+
import { describe, it, expect } from '@jest/globals';
44

55
import { SearchOptions } from "./BasicSearchManager.js"
66
import { SearchRequestType, SearchRequest, SearchRequestTypeId } from "./SearchRequest.js";

src/search/SearchQueryBuilder.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { CveResult } from '../result/CveResult.js';
2+
import { IsoDate } from '../common/IsoDate/IsoDate.js';
3+
import { IsoDatetime } from '../common/IsoDate/IsoDatetime.js';
4+
import { toSearchDateDslString } from '../adapters/search/OpensearchDatetimeUtils.js';
25
import { SearchOptions } from './BasicSearchManager.js';
36
import { SearchRequest } from './SearchRequest.js';
47

@@ -85,11 +88,13 @@ export class SearchQueryBuilder {
8588
if (isDate) {
8689
// assemble all the date fields into an array
8790
let dateFields = [];
91+
const startDate = (this._searchText.length > 10) ? IsoDatetime.parse(this._searchText) : IsoDate.parse(this._searchText);
92+
const startDateStr = toSearchDateDslString(startDate)
8893
SearchQueryBuilder.kDateFieldPaths.map(path => {
8994
let field = `{
9095
"range": {
9196
"${path}": {
92-
"gte": "${this._searchText}"
97+
"gte": "${startDateStr}"
9398
}
9499
}
95100
}`;

src/search/__snapshots__/SearchQueryBuilder.test.unit.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,14 @@ CveResult {
108108
Object {
109109
"range": Object {
110110
"containers.cna.providerMetadata.dateUpdated": Object {
111-
"gte": "2023-12-21",
111+
"gte": "2023-12-21||/d",
112112
},
113113
},
114114
},
115115
Object {
116116
"range": Object {
117117
"containers.cna.timeline.time": Object {
118-
"gte": "2023-12-21",
118+
"gte": "2023-12-21||/d",
119119
},
120120
},
121121
},

0 commit comments

Comments
 (0)