Skip to content

Commit f4a4569

Browse files
committed
feat: Auto cast loose typed columns
https://harperdb.atlassian.net/browse/STUDIO-451
1 parent 6e80581 commit f4a4569

File tree

10 files changed

+435
-24
lines changed

10 files changed

+435
-24
lines changed

src/features/instance/operations/queries/getSearchByConditions.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {
22
parseComparator,
3-
translateBooleanValue,
43
translateColumnFilterToSearchCondition,
54
translateColumnFilterToSearchConditions,
65
} from '@/features/instance/operations/queries/getSearchByConditions';
76
import type { InstanceAttribute } from '@/lib/api.patch';
7+
import { translateKnownBooleanTypedValue } from '@/lib/boolean/translateKnownBooleanTypedValue';
88
import { describe, expect, it } from 'vitest';
99

1010
describe('parseComparator', () => {
@@ -72,13 +72,13 @@ describe('translateBooleanValue', () => {
7272
it('recognizes truthy variants (case-insensitive)', () => {
7373
const truthy = ['true', 'Yes', 'OK', 'Yup', '1', 'Si', 'bet', 'TrU'];
7474
for (const v of truthy) {
75-
expect(translateBooleanValue(v)).toBe(true);
75+
expect(translateKnownBooleanTypedValue(v)).toBe(true);
7676
}
7777
});
7878
it('returns false for non-matching values', () => {
7979
const falsy = ['false', 'no', '0', 'nah', 'maybe', 'foo', '', 'truth'];
8080
for (const v of falsy) {
81-
expect(translateBooleanValue(v)).toBe(false);
81+
expect(translateKnownBooleanTypedValue(v)).toBe(false);
8282
}
8383
});
8484
});

src/features/instance/operations/queries/getSearchByConditions.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { InstanceClientIdConfig } from '@/config/instanceClientConfig';
22
import { InstanceAttribute } from '@/lib/api.patch';
3+
import { translateKnownBooleanTypedValue } from '@/lib/boolean/translateKnownBooleanTypedValue';
4+
import { autoCast } from '@/lib/casting/autoCast';
35
import { queryOptions } from '@tanstack/react-query';
46

57
interface GetSearchByConditionsParams extends InstanceClientIdConfig {
@@ -86,7 +88,7 @@ export function getSearchByConditionsOptions({
8688

8789
export function translateColumnFilterToSearchConditions(key: string, rawValues: string, attribute: InstanceAttribute | undefined): SearchCondition[] {
8890
const split = rawValues.split(/ & /);
89-
return split.map(rawValue => translateColumnFilterToSearchCondition(key, rawValue, attribute))
91+
return split.map(rawValue => translateColumnFilterToSearchCondition(key, rawValue, attribute));
9092
}
9193

9294
export function translateColumnFilterToSearchCondition(key: string, rawValue: string, attribute: InstanceAttribute | undefined): SearchCondition {
@@ -129,18 +131,23 @@ export function translateColumnFilterToSearchCondition(key: string, rawValue: st
129131
return {
130132
search_attribute: key,
131133
search_type: comparator,
132-
search_value: translateBooleanValue(value),
134+
search_value: translateKnownBooleanTypedValue(value),
133135
};
134136
}
135-
case 'Any':
136137
case 'Blob':
137138
case 'Bytes':
138-
default:
139139
return {
140140
search_attribute: key,
141141
search_type: comparator,
142142
search_value: value,
143143
};
144+
case 'Any':
145+
default:
146+
return {
147+
search_attribute: key,
148+
search_type: comparator,
149+
search_value: autoCast(value),
150+
};
144151
}
145152
}
146153

@@ -236,20 +243,3 @@ export function parseComparator(value: string): { comparator: Comparator, value:
236243
value: value,
237244
};
238245
}
239-
240-
const acceptedOKValues = [
241-
'1',
242-
'bet',
243-
'k',
244-
'ok',
245-
'si',
246-
'tru',
247-
'true',
248-
'yes',
249-
'yup',
250-
];
251-
252-
export function translateBooleanValue(value: string): boolean {
253-
const lowerValue = value.toLowerCase();
254-
return acceptedOKValues.includes(lowerValue);
255-
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { translateKnownBooleanTypedValue } from './translateKnownBooleanTypedValue';
3+
4+
describe('translateKnownBooleanTypedValue', () => {
5+
it('returns true for all accepted true-like strings (case-insensitive)', () => {
6+
const trueLikes = [
7+
'1',
8+
'bet',
9+
'k',
10+
'ok',
11+
'si',
12+
'tru',
13+
'true',
14+
'yes',
15+
'yup',
16+
];
17+
18+
for (const val of trueLikes) {
19+
// exact lower-case
20+
expect(translateKnownBooleanTypedValue(val)).toBe(true);
21+
// UPPER-CASE
22+
expect(translateKnownBooleanTypedValue(val.toUpperCase())).toBe(true);
23+
// Mixed case
24+
const mixed = val[0].toUpperCase() + val.slice(1).toLowerCase();
25+
expect(translateKnownBooleanTypedValue(mixed)).toBe(true);
26+
}
27+
});
28+
29+
it('returns false for common false-like or unrelated strings', () => {
30+
const falseLikes = [
31+
'0',
32+
'false',
33+
'no',
34+
'n',
35+
'nah',
36+
'nope',
37+
'off',
38+
'on',
39+
'',
40+
'2',
41+
'maybe',
42+
'certainly',
43+
];
44+
45+
for (const val of falseLikes) {
46+
expect(translateKnownBooleanTypedValue(val)).toBe(false);
47+
expect(translateKnownBooleanTypedValue(val.toUpperCase())).toBe(false);
48+
}
49+
});
50+
51+
it('trims whitespace', () => {
52+
expect(translateKnownBooleanTypedValue(' yes')).toBe(true);
53+
expect(translateKnownBooleanTypedValue('yes ')).toBe(true);
54+
expect(translateKnownBooleanTypedValue('\tyes')).toBe(true);
55+
expect(translateKnownBooleanTypedValue('true\n')).toBe(true);
56+
});
57+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const acceptedTrueValues = [
2+
'1',
3+
'bet',
4+
'k',
5+
'ok',
6+
'si',
7+
'tru',
8+
'true',
9+
'yes',
10+
'yup',
11+
];
12+
13+
export function translateKnownBooleanTypedValue(value: string): boolean {
14+
const lowerValue = value.toLowerCase().trim();
15+
return acceptedTrueValues.includes(lowerValue);
16+
}

src/lib/casting/autoCast.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { autoCast } from './autoCast';
3+
4+
// This suite verifies the current behavior of autoCast:
5+
// - Passthrough for null/undefined/empty string and any non-string inputs
6+
// - Common string mappings (true/false/null/undefined/NaN with specific casing)
7+
// - Numeric casting via autoCasterIsNumberCheck rules
8+
// - ISO 8601 datetime recognition with timezone into Date objects
9+
// - Everything else remains the original string
10+
11+
describe('autoCast', () => {
12+
describe('passthrough values', () => {
13+
it('returns null, undefined, and empty string unchanged', () => {
14+
expect(autoCast(null)).toBeNull();
15+
expect(autoCast(undefined)).toBeUndefined();
16+
expect(autoCast('')).toBe('');
17+
});
18+
19+
it('returns non-string inputs unchanged (by identity when applicable)', () => {
20+
const num = 42;
21+
expect(autoCast(num)).toBe(num);
22+
23+
const bool = true;
24+
expect(autoCast(bool)).toBe(bool);
25+
26+
const obj = { a: 1 } as const;
27+
expect(autoCast(obj)).toBe(obj);
28+
29+
const arr = [1, 2, 3] as const;
30+
expect(autoCast(arr)).toBe(arr);
31+
32+
const d = new Date('2020-01-01T00:00:00.000Z');
33+
expect(autoCast(d)).toBe(d);
34+
});
35+
});
36+
37+
describe('common string mappings', () => {
38+
it('maps selected tokens to booleans/null/NaN', () => {
39+
expect(autoCast('true')).toBe(true);
40+
expect(autoCast('TRUE')).toBe(true);
41+
expect(autoCast('false')).toBe(false);
42+
expect(autoCast('FALSE')).toBe(false);
43+
44+
// string "undefined" maps to null (not undefined)
45+
expect(autoCast('undefined')).toBeNull();
46+
47+
// various null casings handled as configured
48+
expect(autoCast('null')).toBeNull();
49+
expect(autoCast('NULL')).toBeNull();
50+
51+
const nanResult = autoCast('NaN');
52+
expect(typeof nanResult).toBe('number');
53+
expect(Number.isNaN(nanResult as number)).toBe(true);
54+
});
55+
56+
it('does not map non-configured variants', () => {
57+
// Not configured in the table: remains a string
58+
expect(autoCast('True')).toBe('True');
59+
expect(autoCast('False')).toBe('False');
60+
expect(autoCast('UNDEFINED')).toBe('UNDEFINED');
61+
expect(autoCast('nan')).toBe('nan');
62+
});
63+
});
64+
65+
describe('numeric casting via autoCasterIsNumberCheck', () => {
66+
it('casts common numeric strings to numbers', () => {
67+
expect(autoCast('0')).toBe(0);
68+
expect(autoCast('5')).toBe(5);
69+
expect(autoCast(' 1 ')).toBe(1);
70+
expect(autoCast('0.5')).toBe(0.5);
71+
expect(autoCast('.5')).toBe(0.5);
72+
expect(autoCast('-.5')).toBe(-0.5);
73+
});
74+
75+
it('produces Infinity for the string "Infinity"', () => {
76+
expect(autoCast('Infinity')).toBe(Infinity);
77+
expect(autoCast('-Infinity')).toBe(-Infinity);
78+
});
79+
80+
it('rejects certain numeric-like forms and returns the original string', () => {
81+
// leading-zero integers are rejected
82+
expect(autoCast('0123')).toBe('0123');
83+
expect(autoCast('00')).toBe('00');
84+
85+
// scientific notation rejected
86+
expect(autoCast('1e3')).toBe('1e3');
87+
expect(autoCast('2E6')).toBe('2E6');
88+
89+
// numeric prefixes rejected
90+
expect(autoCast('0x10')).toBe('0x10');
91+
expect(autoCast('0b10')).toBe('0b10');
92+
expect(autoCast('0o10')).toBe('0o10');
93+
});
94+
});
95+
96+
describe('ISO datetime casting', () => {
97+
it('casts ISO date-time strings with timezone to Date', () => {
98+
const d1 = autoCast('2020-01-01T12:30Z');
99+
expect(d1).toBeInstanceOf(Date);
100+
expect((d1 as Date).toISOString()).toBe('2020-01-01T12:30:00.000Z');
101+
102+
const d2 = autoCast('2020-01-01T12:30:45Z');
103+
expect(d2).toBeInstanceOf(Date);
104+
expect((d2 as Date).toISOString()).toBe('2020-01-01T12:30:45.000Z');
105+
106+
const d3 = autoCast('2020-01-01T12:30:45.123Z');
107+
expect(d3).toBeInstanceOf(Date);
108+
expect((d3 as Date).toISOString()).toBe('2020-01-01T12:30:45.123Z');
109+
110+
const d4 = autoCast('2020-01-01T12:30+02:00');
111+
expect(d4).toBeInstanceOf(Date);
112+
expect((d4 as Date).toISOString()).toBe('2020-01-01T10:30:00.000Z');
113+
});
114+
115+
it('does not cast non-ISO or timezone-less date strings', () => {
116+
expect(autoCast('2020-01-01')).toBe('2020-01-01');
117+
expect(autoCast('2020-01-01T12:30')).toBe('2020-01-01T12:30');
118+
expect(autoCast('2020-01-01 12:30Z')).toBe('2020-01-01 12:30Z');
119+
});
120+
});
121+
122+
it('returns a value with the expected runtime type', () => {
123+
const a = autoCast('true');
124+
expect(typeof a).toBe('boolean');
125+
126+
const b = autoCast('42');
127+
expect(typeof b).toBe('number');
128+
129+
const c = autoCast('2020-01-01T00:00Z');
130+
expect(c instanceof Date).toBe(true);
131+
132+
const d = autoCast('not-a-special-value');
133+
expect(typeof d).toBe('string');
134+
});
135+
});

src/lib/casting/autoCast.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { autoCasterIsNumberCheck } from '@/lib/numbers/autoCasterIsNumberCheck';
2+
3+
const autoCastCommonStringsMap: Record<string, boolean | null | number> = {
4+
true: true,
5+
TRUE: true,
6+
false: false,
7+
FALSE: false,
8+
undefined: null,
9+
null: null,
10+
NULL: null,
11+
NaN: NaN,
12+
};
13+
const isoDateRegex =
14+
/^((\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z)))$/;
15+
16+
/**
17+
* Takes a raw string value and casts it to a potentially correct data type.
18+
* @param data
19+
* @returns
20+
*/
21+
export function autoCast(data: unknown) {
22+
if (data === null || data === undefined || data === '') {
23+
return data;
24+
}
25+
26+
//if this is already typed other than string, return data
27+
if (typeof data !== 'string') {
28+
return data;
29+
}
30+
31+
// Try to make it a common string
32+
if (autoCastCommonStringsMap[data] !== undefined) {
33+
return autoCastCommonStringsMap[data];
34+
}
35+
36+
if (autoCasterIsNumberCheck(data)) {
37+
return Number(data);
38+
}
39+
40+
if (isoDateRegex.test(data)) return new Date(data);
41+
42+
return data;
43+
}

0 commit comments

Comments
 (0)