Skip to content

Commit 841bd96

Browse files
committed
Hardning test coverage
1 parent 6201252 commit 841bd96

7 files changed

Lines changed: 904 additions & 51 deletions

File tree

src/flashql/eval/ExprEngine.js

Lines changed: 191 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { registry } from '../../lang/registry.js';
55
const TBL_PLACEHOLDER = Symbol.for('tbl_placeholder');
66
const GROUPING_META = Symbol.for('grouping_meta');
77
const WINDOW_META = Symbol.for('window_meta');
8+
const hasExplicitTimezone = (value) => typeof value === 'string'
9+
&& /(?:z|[+-]\d{2}(?::?\d{2})?)$/i.test(value.trim());
810

911
export class ExprEngine {
1012

@@ -196,17 +198,104 @@ export class ExprEngine {
196198
);
197199
}
198200
// -----------
201+
if (L === null || L === undefined) return null;
199202
const DT = dataType.value();
203+
const asString = (value) => String(value);
204+
const asNumber = (value) => Number(value);
205+
const asIsoTimestamp = (date) => date.toISOString().slice(0, 19).replace('T', ' ');
206+
const asIsoTime = (date) => date.toISOString().slice(11, 19);
207+
const normalizeBigInt = (value) => {
208+
if (typeof value === 'bigint') return value;
209+
if (typeof value === 'number') return BigInt(Math.trunc(value));
210+
if (typeof value === 'string') return BigInt(value.trim());
211+
return BigInt(value);
212+
};
213+
const normalizeJson = (value) => {
214+
if (typeof value !== 'string') return value;
215+
return JSON.parse(value);
216+
};
217+
const normalizeBytea = (value) => {
218+
if (value instanceof Uint8Array) return value;
219+
if (typeof Buffer !== 'undefined' && value instanceof Buffer) return value;
220+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
221+
if (Array.isArray(value)) return Uint8Array.from(value);
222+
if (typeof value === 'string') return new TextEncoder().encode(value);
223+
return value;
224+
};
225+
const normalizeDate = (value) => {
226+
if (value instanceof Date) return value.toISOString().slice(0, 10);
227+
const str = String(value).trim();
228+
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
229+
return new Date(str).toISOString().slice(0, 10);
230+
};
231+
const normalizeTime = (value) => {
232+
if (value instanceof Date) return asIsoTime(value);
233+
const str = String(value).trim();
234+
const match = str.match(/\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?/);
235+
if (match) return match[0];
236+
return asIsoTime(new Date(str));
237+
};
238+
const normalizeTimestamp = (value) => {
239+
if (value instanceof Date) return asIsoTimestamp(value);
240+
const str = String(value).trim();
241+
if (/^\d{4}-\d{2}-\d{2}[ t]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$/.test(str)) {
242+
return str.replace('T', ' ');
243+
}
244+
return asIsoTimestamp(new Date(str));
245+
};
246+
const normalizeTimestamptz = (value) => {
247+
if (value instanceof Date) return value.toISOString();
248+
const str = String(value).trim();
249+
if (/^\d{4}-\d{2}-\d{2}[ t]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:z|[+-]\d{2}(?::?\d{2})?)$/i.test(str)) {
250+
return str;
251+
}
252+
return new Date(str).toISOString();
253+
};
200254
switch (DT) {
201255
case 'SMALLINT':
202256
case 'INT':
203257
case 'INTEGER':
204-
case 'BIGINT':
205258
case 'SERIAL':
259+
return asNumber(L);
260+
case 'NUMERIC':
261+
case 'DECIMAL':
262+
case 'REAL':
263+
case 'DOUBLE PRECISION':
264+
return asNumber(L);
265+
case 'BIGINT':
206266
case 'BIGSERIAL':
207-
return Number(L);
208-
case 'TEXT': return String(L);
209-
case 'BOOLEAN': return Boolean(L);
267+
return normalizeBigInt(L);
268+
case 'TEXT':
269+
case 'VARCHAR':
270+
case 'CHAR':
271+
case 'UUID':
272+
case 'INTERVAL':
273+
return asString(L);
274+
case 'JSON':
275+
case 'JSONB':
276+
return normalizeJson(L);
277+
case 'ARRAY':
278+
return typeof L === 'string' ? JSON.parse(L) : L;
279+
case 'BYTEA':
280+
return normalizeBytea(L);
281+
case 'DATE':
282+
return normalizeDate(L);
283+
case 'TIME':
284+
return normalizeTime(L);
285+
case 'TIMESTAMP':
286+
return normalizeTimestamp(L);
287+
case 'TIMESTAMPTZ':
288+
return normalizeTimestamptz(L);
289+
case 'BOOLEAN':
290+
if (typeof L === 'boolean') return L;
291+
if (typeof L === 'number') return L !== 0;
292+
if (typeof L === 'bigint') return L !== 0n;
293+
if (typeof L === 'string') {
294+
const normalized = L.trim().toLowerCase();
295+
if (['true', 't', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
296+
if (['false', 'f', '0', 'no', 'n', 'off'].includes(normalized)) return false;
297+
}
298+
return Boolean(L);
210299
default: return L;
211300
}
212301
}
@@ -547,14 +636,70 @@ export class ExprEngine {
547636

548637
case 'UPPER': return String(args[0] ?? '').toUpperCase();
549638

639+
case 'CONCAT': return args.map((arg) => arg == null ? '' : String(arg)).join('');
640+
641+
case 'CONCAT_WS': {
642+
const [separator, ...rest] = args;
643+
return rest
644+
.filter((arg) => arg != null)
645+
.map((arg) => String(arg))
646+
.join(String(separator ?? ''));
647+
}
648+
649+
case 'TRIM':
650+
return args[0] == null ? null : String(args[0]).trim();
651+
652+
case 'LTRIM':
653+
return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
654+
655+
case 'RTRIM':
656+
return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
657+
550658
case 'LENGTH': return args[0] == null ? null : String(args[0]).length;
551659

552660
case 'ABS': return Math.abs(Number(args[0]));
553661

662+
case 'ROUND': {
663+
const value = Number(args[0]);
664+
const scale = args[1] == null ? 0 : Number(args[1]);
665+
const factor = 10 ** scale;
666+
return Math.round(value * factor) / factor;
667+
}
668+
669+
case 'FLOOR': return Math.floor(Number(args[0]));
670+
671+
case 'CEIL':
672+
case 'CEILING': return Math.ceil(Number(args[0]));
673+
674+
case 'GREATEST':
675+
return args.reduce((max, cur) => max == null || cur > max ? cur : max, null);
676+
677+
case 'LEAST':
678+
return args.reduce((min, cur) => min == null || cur < min ? cur : min, null);
679+
554680
case 'COALESCE': return args.reduce((prev, cur) => prev !== null ? prev : cur, null);
555681

556682
case 'NULLIF': return _eq(args[0], args[1]) ? null : args[0];
557683

684+
case 'REPLACE':
685+
return args[0] == null ? null : String(args[0]).split(String(args[1] ?? '')).join(String(args[2] ?? ''));
686+
687+
case 'SUBSTRING':
688+
case 'SUBSTR': {
689+
if (args[0] == null) return null;
690+
const source = String(args[0]);
691+
const start = Math.max(Number(args[1] ?? 1) - 1, 0);
692+
const length = args[2] == null ? undefined : Math.max(Number(args[2]), 0);
693+
return length === undefined ? source.slice(start) : source.slice(start, start + length);
694+
}
695+
696+
case 'POSITION': {
697+
const needle = String(args[0] ?? '');
698+
const haystack = String(args[1] ?? '');
699+
const idx = haystack.indexOf(needle);
700+
return idx < 0 ? 0 : idx + 1;
701+
}
702+
558703
case 'JSON_BUILD_ARRAY':
559704
case 'JSON_ARRAY': return args;
560705

@@ -577,31 +722,60 @@ export class ExprEngine {
577722

578723
case 'CURRENT_TIMESTAMP': return new Date().toISOString();
579724

725+
case 'MAKE_DATE': {
726+
const [year, month, day] = args.map((arg) => Number(arg));
727+
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
728+
}
729+
730+
case 'MAKE_TIME': {
731+
const [hour, minute, second] = args.map((arg) => Number(arg));
732+
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
733+
}
734+
735+
case 'MAKE_TIMESTAMP': {
736+
const [year, month, day, hour, minute, second] = args.map((arg) => Number(arg));
737+
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
738+
}
739+
580740
case 'EXTRACT':
581741
const [field, date1] = args;
582742
const dateObj1 = new Date(date1);
583743
const fieldName = field.toUpperCase();
744+
const useUTC1 = hasExplicitTimezone(date1);
745+
const getYear1 = useUTC1 ? dateObj1.getUTCFullYear() : dateObj1.getFullYear();
746+
const getMonth1 = useUTC1 ? dateObj1.getUTCMonth() : dateObj1.getMonth();
747+
const getDay1 = useUTC1 ? dateObj1.getUTCDate() : dateObj1.getDate();
748+
const getHour1 = useUTC1 ? dateObj1.getUTCHours() : dateObj1.getHours();
749+
const getMinute1 = useUTC1 ? dateObj1.getUTCMinutes() : dateObj1.getMinutes();
750+
const getSecond1 = useUTC1 ? dateObj1.getUTCSeconds() : dateObj1.getSeconds();
584751
switch (fieldName) {
585-
case 'YEAR': return dateObj1.getFullYear();
586-
case 'MONTH': return dateObj1.getMonth() + 1;
587-
case 'DAY': return dateObj1.getDate();
588-
case 'HOUR': return dateObj1.getHours();
589-
case 'MINUTE': return dateObj1.getMinutes();
590-
case 'SECOND': return dateObj1.getSeconds();
752+
case 'YEAR': return getYear1;
753+
case 'MONTH': return getMonth1 + 1;
754+
case 'DAY': return getDay1;
755+
case 'HOUR': return getHour1;
756+
case 'MINUTE': return getMinute1;
757+
case 'SECOND': return getSecond1;
591758
default: throw new Error(`Unsupported date/time field ${field}`);
592759
}
593760

594761
case 'DATE_TRUNC':
595762
const [dateUnit, date2] = args;
596763
const dateObj2 = new Date(date2);
597764
const unitName = dateUnit.toUpperCase();
765+
const useUTC2 = hasExplicitTimezone(date2);
766+
const getYear2 = useUTC2 ? dateObj2.getUTCFullYear() : dateObj2.getFullYear();
767+
const getMonth2 = (useUTC2 ? dateObj2.getUTCMonth() : dateObj2.getMonth()) + 1;
768+
const getDay2 = useUTC2 ? dateObj2.getUTCDate() : dateObj2.getDate();
769+
const getHour2 = useUTC2 ? dateObj2.getUTCHours() : dateObj2.getHours();
770+
const getMinute2 = useUTC2 ? dateObj2.getUTCMinutes() : dateObj2.getMinutes();
771+
const getSecond2 = useUTC2 ? dateObj2.getUTCSeconds() : dateObj2.getSeconds();
598772
switch (unitName) {
599-
case 'YEAR': return `${dateObj2.getFullYear()}-01-01`;
600-
case 'MONTH': return `${dateObj2.getFullYear()}-${String(dateObj2.getMonth() + 1).padStart(2, '0')}-01`;
601-
case 'DAY': return `${dateObj2.getFullYear()}-${String(dateObj2.getMonth() + 1).padStart(2, '0')}-${String(dateObj2.getDate()).padStart(2, '0')}`;
602-
case 'HOUR': return `${dateObj2.getFullYear()}-${String(dateObj2.getMonth() + 1).padStart(2, '0')}-${String(dateObj2.getDate()).padStart(2, '0')}T${String(dateObj2.getHours()).padStart(2, '0')}:00:00`;
603-
case 'MINUTE': return `${dateObj2.getFullYear()}-${String(dateObj2.getMonth() + 1).padStart(2, '0')}-${String(dateObj2.getDate()).padStart(2, '0')}T${String(dateObj2.getHours()).padStart(2, '0')}:${String(dateObj2.getMinutes()).padStart(2, '0')}:00`;
604-
case 'SECOND': return `${dateObj2.getFullYear()}-${String(dateObj2.getMonth() + 1).padStart(2, '0')}-${String(dateObj2.getDate()).padStart(2, '0')}T${String(dateObj2.getHours()).padStart(2, '0')}:${String(dateObj2.getMinutes()).padStart(2, '0')}:${String(dateObj2.getSeconds()).padStart(2, '0')}`;
773+
case 'YEAR': return `${getYear2}-01-01`;
774+
case 'MONTH': return `${getYear2}-${String(getMonth2).padStart(2, '0')}-01`;
775+
case 'DAY': return `${getYear2}-${String(getMonth2).padStart(2, '0')}-${String(getDay2).padStart(2, '0')}`;
776+
case 'HOUR': return `${getYear2}-${String(getMonth2).padStart(2, '0')}-${String(getDay2).padStart(2, '0')}T${String(getHour2).padStart(2, '0')}:00:00`;
777+
case 'MINUTE': return `${getYear2}-${String(getMonth2).padStart(2, '0')}-${String(getDay2).padStart(2, '0')}T${String(getHour2).padStart(2, '0')}:${String(getMinute2).padStart(2, '0')}:00`;
778+
case 'SECOND': return `${getYear2}-${String(getMonth2).padStart(2, '0')}-${String(getDay2).padStart(2, '0')}T${String(getHour2).padStart(2, '0')}:${String(getMinute2).padStart(2, '0')}:${String(getSecond2).padStart(2, '0')}`;
605779
default: throw new Error(`Unsupported date/time unit ${dateUnit}`);
606780
}
607781

@@ -999,4 +1173,4 @@ function likeCompare(str, pattern) {
9991173
} catch {
10001174
return false;
10011175
}
1002-
}
1176+
}

src/flashql/eval/QueryEngine.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,12 +510,18 @@ export class QueryEngine {
510510
// Resolve column names
511511
const definedColumns = Object.fromEntries(tableSchema.columns().map((col) => [col.name().value(), col]));
512512
const columnNames = stmtNode.columnList()?.entries().map((col) => col.value())
513-
|| Object.keys(definedColumns);
513+
|| tableSchema.columns()
514+
.map((col) => col.name().value())
515+
.filter((colName) => {
516+
const colSchema = definedColumns[colName];
517+
return !colName.startsWith('__')
518+
&& !colSchema.expressionConstraint?.();
519+
});
514520

515521
// Resolve defaults and constraints
516522
const defaultRecord = Object.create(null);
517523
for (const [colName, colSchema] of Object.entries(definedColumns)) {
518-
if (colName.startsWith('__') && !columnNames.includes(colName)) continue;
524+
if (!columnNames.includes(colName)) continue;
519525

520526
defaultRecord[colName] = null;
521527
if (_.cons = colSchema.defaultConstraint()) {

0 commit comments

Comments
 (0)