Skip to content

Commit 2768085

Browse files
authored
Merge pull request #45 from CVEProject/hk/019_date_search
Resolves issue #19 and #20 (simple date search)
2 parents b405e06 + 0beb37e commit 2768085

22 files changed

+1233
-61
lines changed

config/devel.jsonc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"fixtures": {
3232
// @todo these constants needs to be in sync in cve-fixtures
3333
// so that testing snapshots are consistent and valid
34-
"name": "fixtures-search-baseline-1086", // release tag
35-
"numCves": "1086" // possible identifier assuming we always add cves to a new release
34+
"name": "fixtures-search-baseline-1008", // release tag
35+
"numCves": "1008" // possible identifier assuming we always add cves to a new release
3636
}
3737
},
3838
// constants for testing node-config

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
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+
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Unit tests for IsoDate
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+
*/
12+
13+
import { describe, test, expect } from '@jest/globals';
14+
import { IsoDate, isValidIsoDate } from './IsoDate';
15+
16+
describe('IsoDate.parse – valid inputs', () => {
17+
18+
test('full date with hyphens', () => {
19+
const str = '2025-01-01';
20+
expect(isValidIsoDate(str)).toBeTruthy();
21+
const d = IsoDate.parse(str);
22+
expect(d.year).toBe(2025);
23+
expect(d.month).toBe(1);
24+
expect(d.day).toBe(1);
25+
expect(d.toString()).toBe('2025-01-01');
26+
expect(d.isDate()).toBeTruthy();
27+
expect(d.isMonth()).toBeFalsy();
28+
expect(d.isYear()).toBeFalsy()
29+
});
30+
31+
test('year‑month', () => {
32+
const str = '2025-01';
33+
expect(isValidIsoDate(str)).toBeTruthy();
34+
const d = IsoDate.parse(str);
35+
expect(d.year).toBe(2025);
36+
expect(d.month).toBe(1);
37+
expect(d.day).toBeUndefined();
38+
expect(d.toString()).toBe('2025-01');
39+
expect(d.isDate()).toBeFalsy();
40+
expect(d.isMonth()).toBeTruthy();
41+
expect(d.isYear()).toBeFalsy()
42+
});
43+
44+
test('year only', () => {
45+
const str = '2025';
46+
expect(isValidIsoDate(str)).toBeTruthy();
47+
const d = IsoDate.parse(str);
48+
expect(d.year).toBe(2025);
49+
expect(d.month).toBeUndefined();
50+
expect(d.day).toBeUndefined();
51+
expect(d.toString()).toBe('2025');
52+
expect(d.isDate()).toBeFalsy();
53+
expect(d.isMonth()).toBeFalsy();
54+
expect(d.isYear()).toBeTruthy()
55+
});
56+
57+
test('leap‑year February 29', () => {
58+
const str = '2024-02-29';
59+
expect(isValidIsoDate(str)).toBeTruthy();
60+
const d = IsoDate.parse(str);
61+
expect(d.year).toBe(2024);
62+
expect(d.month).toBe(2);
63+
expect(d.day).toBe(29);
64+
expect(d.toString()).toBe('2024-02-29');
65+
});
66+
67+
test('month‑day boundary', () => {
68+
const str = '2025-01-30';
69+
expect(isValidIsoDate(str)).toBeTruthy();
70+
const d = IsoDate.parse(str);
71+
expect(d.year).toBe(2025);
72+
expect(d.month).toBe(1);
73+
expect(d.day).toBe(30);
74+
expect(d.toString()).toBe('2025-01-30');
75+
});
76+
});
77+
78+
describe('IsoDate.parse – invalid inputs', () => {
79+
const invalid = [
80+
'202501', // year+month without hyphen
81+
'20250101', // compact full date (properly rejected in this class)
82+
'250101', // two‑digit year
83+
'2025-13-01', // invalid month
84+
'2025-02-30', // invalid day (Feb 30)
85+
'2025-04-31', // invalid day (April 31)
86+
'-2025-04-31', // invalid year
87+
'--01-01', // leading hyphens
88+
'-2025-01', // leading hyphen before year
89+
'2025--01', // double hyphen between year and month
90+
'2025-01--01', // double hyphen before day
91+
'2025-02-29', // illegal leap year
92+
'2025-01-01T014:00:00:00Z', // datetime does not match in this class
93+
];
94+
95+
invalid.forEach((value) => {
96+
test(`throws for "${value}"`, () => {
97+
expect(() => IsoDate.parse(value)).toThrow(Error);
98+
});
99+
});
100+
101+
invalid.forEach((value) => {
102+
test(`"${value}" is not an IsoDate`, () => {
103+
expect(isValidIsoDate(value)).toBeFalsy();
104+
});
105+
});
106+
});
107+
108+
describe('IsoDate.toString', () => {
109+
const tests: Array<{ input: string; expected: string; }> = [
110+
{ input: '2025-01-01', expected: '2025-01-01' },
111+
{ input: '2025-01', expected: '2025-01' },
112+
{ input: '2025', expected: '2025' }
113+
];
114+
115+
tests.forEach(({input, expected}) => {
116+
test(`properly prints out '${input}' as '${expected}'`, () => {
117+
const isoDate = IsoDate.parse(input)
118+
expect(isoDate.toString()).toBe(expected)
119+
});
120+
});
121+
})

src/common/IsoDate/IsoDate.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* IsoDate – a lightweight class for representing calendar dates.
3+
*
4+
* Supported input formats:
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')
10+
*
11+
* Note we do not support (even though it is allowed by ISO 8601)
12+
* - "compact date" (i.e., YYYYMMDD)
13+
* - years previous to 1 AD (i.e., zero and negative years)
14+
* - years after 2500
15+
*
16+
* The class validates monthly day rules and leap‑year rules.
17+
* It provides a normalized `toString()` output:
18+
* - full date → YYYY-MM-DD
19+
* - year‑month → YYYY-MM
20+
* - year only → YYYY
21+
*
22+
* Example:
23+
* const d = IsoDate.parse('2025-01-01');
24+
* console.log(d.year, d.month, d.day); // 2025 1 1
25+
* console.log(d.toString()); // "2025-01-01"
26+
*
27+
* AI Usage:
28+
* - This code was originally generated using
29+
* 1. OpenAI/GPT OSS 120b on Roo Code
30+
* 2. Gemini 2.5 Flash and 2.5 Pro
31+
* then modified to fix incorrect implementations and fit project needs.
32+
* The first commit contains these corrections so that all code committed
33+
* works as designed.
34+
*/
35+
36+
export class IsoDate {
37+
38+
/** Full year (e.g., 2025) */
39+
public readonly year: number;
40+
/** Month number 1‑12 (optional) */
41+
public readonly month?: number;
42+
/** Day number 1‑31 (optional, requires month) */
43+
public readonly day?: number;
44+
45+
protected constructor(year: number, month?: number, day?: number) {
46+
this.year = year;
47+
if (month !== undefined) this.month = month;
48+
if (day !== undefined) this.day = day;
49+
}
50+
51+
/**
52+
* Parse a string into a IsoDate.
53+
* Throws an Error if the string does not match any supported format
54+
* or if the date components are out of range.
55+
*/
56+
public static parse(value: string): IsoDate {
57+
// Regex with named capture groups for clarity.
58+
// 1. YYYY‑MM‑DD
59+
// 2. we do not allow YYYYMMDD anymore
60+
// 3. YYYY‑MM
61+
// 4. YYYY
62+
const regex =
63+
// GPT OSS 120b generated regex
64+
// /^(?<year>\d{4})(?:[-]?(?<month>\d{2})(?:[-]?(?<day>\d{2})?)?)?$/;
65+
/^(?<year>\d{4})(?:[-](?<month>\d{2})(?:[-](?<day>\d{2})?)?)?$/;
66+
67+
const match = regex.exec(value);
68+
if (!match || !match.groups) {
69+
throw new Error(`Invalid calendar date format: "${value}": must be one of YYYY-MM-DD, YYYY-MM, or YYYY`);
70+
}
71+
72+
const year = Number(match.groups.year);
73+
const monthStr = match.groups.month;
74+
const dayStr = match.groups.day;
75+
76+
// Validate year range (reasonable limits)
77+
if (year < 1 || year > 2500) {
78+
throw new Error(`Year out of range: ${year}`);
79+
}
80+
81+
// If month is present, validate it.
82+
if (monthStr !== undefined) {
83+
const month = Number(monthStr);
84+
if (month < 1 || month > 12) {
85+
throw new Error(`Month out of range: ${monthStr}`);
86+
}
87+
88+
// If day is present, validate day according to month & leap year.
89+
if (dayStr !== undefined) {
90+
const day = Number(dayStr);
91+
const maxDay = IsoDate.daysInMonth(year, month);
92+
if (day < 1 || day > maxDay) {
93+
throw new Error(
94+
`Day out of range for ${year}-${String(month).padStart(
95+
2,
96+
'0'
97+
)}: ${dayStr}`
98+
);
99+
}
100+
return new IsoDate(year, month, day);
101+
}
102+
103+
// Month only (no day)
104+
return new IsoDate(year, month);
105+
}
106+
107+
// Year only
108+
return new IsoDate(year);
109+
}
110+
111+
/** Return true if the stored year is a leap year. */
112+
public isLeapYear(): boolean {
113+
return IsoDate.isLeapYear(this.year);
114+
}
115+
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+
132+
/** Normalized string representation. */
133+
public toString(): string {
134+
const y = String(this.year).padStart(4, '0');
135+
if (this.month !== undefined) {
136+
const m = String(this.month).padStart(2, '0');
137+
if (this.day !== undefined) {
138+
const d = String(this.day).padStart(2, '0');
139+
return `${y}-${m}-${d}`;
140+
}
141+
return `${y}-${m}`;
142+
}
143+
return y;
144+
}
145+
146+
/** Static function that returns true iff year is a leap‑year */
147+
public static isLeapYear(year: number): boolean {
148+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
149+
}
150+
151+
/** Static function that returns number of days in a given month/year. */
152+
public static daysInMonth(year: number, month: number): number {
153+
switch (month) {
154+
case 2:
155+
return IsoDate.isLeapYear(year) ? 29 : 28;
156+
case 4:
157+
case 6:
158+
case 9:
159+
case 11:
160+
return 30;
161+
default:
162+
return 31;
163+
}
164+
}
165+
}
166+
167+
/* Utility function to check if a string is a valid ISO date according to IsoDate parsing rules
168+
*/
169+
export function isValidIsoDate(value: string): boolean {
170+
try {
171+
IsoDate.parse(value);
172+
return true;
173+
} catch {
174+
return false;
175+
}
176+
}

src/common/IsoDate/IsoDateString.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { describe, it, expect } from '@jest/globals';
12
import { IsoDateStringRegEx, IsoDateString } from './IsoDateString.js';
23

34
describe(`IsoDateString`, () => {

src/common/IsoDate/IsoDateString.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*
66
* Note that in the future, if necessary, we can extend what this class covers, but for now
77
* this strict and opinionated set is very useful for processing ISO Date+Time+TZ strings
8+
*
9+
* @deprecated Use IsoDatetime or IsoDate instead for safer and more efficient datetime and date functions
810
*/
911

1012
/** a regular expression to represent an ISO Date+Time+TZ string

0 commit comments

Comments
 (0)