// Values by declared field type: what the store holds, and what goes on the // wire. // // GSF holds typed values (DateTime, BigDecimal, enums) and its serializer // writes a DateTime as epoch millis and a BigDecimal as a string, // `value.toPlainString()` (GenesisSetMaskingJsonSerializer.kt, GSF v8.15.29; // recorded: COMMISSION "15.00000"). On a SQL database a BIGDECIMAL column is // NUMERIC(precision, scale), (20, 5) unless the dictionary says otherwise // (BigDecimalSqlPrecisionConfig, SQLBaseTableGenerator.kt), so a value comes // back with exactly that many decimals; SendIt rounds a seed value to it // HALF_UP before writing (BigDecimalCsvTransformer.kt). // // The store holds a declared column's value the way JavaScript can compare // and compute with it: dates as epoch millis, BigDecimals as numbers rounded // to the column scale, numbers and booleans as such (a seed or a handler may // give them as strings). Criteria, ORDER_BY and derived fields therefore see // numbers, as GSF's typed values compare. A BigDecimal becomes the scaled // string only on the wire (encodeRow). Precision is a JavaScript number's: // about 15 significant digits. import type { GenesisFieldType } from '../types.ts'; // BigDecimalSqlPrecisionConfig's default scale. export const DEFAULT_BIGDECIMAL_SCALE = 5; // The scale a table column's values are written with: a BIGDECIMAL's own, // else the default; undefined for any other type. export function columnScale(field: { type: string; scale?: number }): number | undefined { return field.type === 'BIGDECIMAL' ? (field.scale ?? DEFAULT_BIGDECIMAL_SCALE) : undefined; } // What a value needs to be normalized or encoded: its type, and for a // BIGDECIMAL the scale (undefined: as many decimals as the value has). export interface ValueField { name: string; type: GenesisFieldType | string; scale?: number; values?: string[]; } const DECIMAL = /^([+-])?(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/; // Exponents past this are no number JavaScript holds; such a value is left // as given rather than spelled out digit by digit. const MAX_EXPONENT = 1000; // A decimal digit string plus one, carrying ('199' -> '200', '99' -> '100'). function incremented(digits: string): string { const chars = digits.split(''); for (let at = chars.length - 1; at >= 0; at -= 1) { if (chars[at] !== '9') { chars[at] = String(Number(chars[at]) + 1); return chars.join(''); } chars[at] = '0'; } return `1${chars.join('')}`; } // A number or numeric string as BigDecimal.toPlainString() writes it: no // exponent, and with `scale`, exactly that many decimals, rounded HALF_UP // (half away from zero) as setScale does. A number is read as its shortest // decimal form (String(n)), so 1.005 rounds to "1.01" as its literal would. // undefined for anything that isn't a finite decimal. export function decimalText(value: unknown, scale?: number): string | undefined { let text: string; if (typeof value === 'number') { if (!Number.isFinite(value)) return undefined; text = String(value); } else if (typeof value === 'bigint') { text = value.toString(); } else if (typeof value === 'string') { text = value.trim(); } else { return undefined; } const match = DECIMAL.exec(text); if (!match) return undefined; const [, sign = '', whole = '', fraction = '', exponent = '0'] = match; if (whole === '' && fraction === '') return undefined; if (Math.abs(Number(exponent)) > MAX_EXPONENT) return undefined; const digits = whole + fraction; const point = whole.length + Number(exponent); let integerPart = point <= 0 ? '0' : digits .slice(0, point) .padEnd(point, '0') .replace(/^0+(?=\d)/, ''); let fractionPart = point <= 0 ? '0'.repeat(-point) + digits : digits.slice(point); if (integerPart === '') integerPart = '0'; if (scale !== undefined) { const roundUp = fractionPart.length > scale && fractionPart[scale] >= '5'; fractionPart = fractionPart.slice(0, scale).padEnd(scale, '0'); if (roundUp) { const rounded = incremented(integerPart + fractionPart); integerPart = rounded.slice(0, rounded.length - scale) || '0'; fractionPart = rounded.slice(rounded.length - scale); } } const zero = /^0*$/.test(integerPart + fractionPart); return `${sign === '-' && !zero ? '-' : ''}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`; } const NUMERIC = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; const INTEGER = /^[+-]?\d+$/; // yyyyMMdd; Genesis's yyyyMMdd-HH:mm[:ss[.SSS]]; ISO-8601 (a space for the T // too), the offset optional: without one it is UTC, not the machine's zone. const DAY = /^(\d{4})(\d{2})(\d{2})$/; const GENESIS_DATE_TIME = /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/; const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i; const MS_DIGITS = 3; // The matched parts utcMillis takes: year, month, day, hour, minute, second, // then the fraction; an ISO match has the offset after them. const FRACTION_PART = 6; const OFFSET_PART = FRACTION_PART + 2; const MINUTES_PER_HOUR = 60; const MS_PER_MINUTE = 60_000; // Epoch millis of a UTC date and time given as matched digits, or undefined // when a part is out of range (month 13, 30 February, 25:00 ...). function utcMillis(parts: Array): number | undefined { const [year, month, day, hour = 0, minute = 0, second = 0] = parts .slice(0, FRACTION_PART) .map((part) => (part === undefined ? undefined : Number(part))); const ms = Number((parts[FRACTION_PART] ?? '0').padEnd(MS_DIGITS, '0').slice(0, MS_DIGITS)); const time = Date.UTC(year!, month! - 1, day!, hour, minute, second, ms); const date = new Date(time); const exact = date.getUTCFullYear() === year && date.getUTCMonth() === month! - 1 && date.getUTCDate() === day && date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second; return exact ? time : undefined; } // An ISO offset ('Z', '+01:00', '-0530', '+02') in minutes east of UTC. function offsetMinutes(offset: string | undefined): number { if (!offset || offset.toUpperCase() === 'Z') return 0; const digits = offset.slice(1).replace(':', ''); const minutes = Number(digits.slice(0, 2)) * MINUTES_PER_HOUR + Number(digits.slice(2) || '0'); return offset.startsWith('-') ? -minutes : minutes; } // A date as epoch millis: a number, a Date, an integer, yyyyMMdd, Genesis's // yyyyMMdd-HH:mm[:ss[.SSS]] or ISO-8601. undefined for anything else, or a // date that doesn't exist; nothing is guessed (no Date.parse). export function epochMillis(value: unknown): number | undefined { if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; if (value instanceof Date) { const time = value.getTime(); return Number.isNaN(time) ? undefined : time; } if (typeof value !== 'string') return undefined; const text = value.trim(); // Eight digits are a day (yyyyMMdd), not a moment in January 1970. const day = DAY.exec(text); if (day) return utcMillis(day.slice(1)); if (INTEGER.test(text)) { const millis = Number(text); return Number.isSafeInteger(millis) ? millis : undefined; } const genesis = GENESIS_DATE_TIME.exec(text); if (genesis) return utcMillis(genesis.slice(1)); const iso = ISO_DATE_TIME.exec(text); if (!iso) return undefined; const time = utcMillis(iso.slice(1, OFFSET_PART)); return time === undefined ? undefined : time - offsetMinutes(iso[OFFSET_PART]) * MS_PER_MINUTE; } const INTEGER_TYPES = new Set(['INT', 'SHORT', 'LONG', 'NANO_TIMESTAMP']); const DATE_TYPES = new Set(['DATE', 'DATETIME']); // A finite number from decimal text, or undefined. function finite(text: string | undefined): number | undefined { if (text === undefined) return undefined; const number = Number(text); return Number.isFinite(number) ? number : undefined; } // A value written to a declared column, as the store holds it (see the // header). A value that can't be read as its type is kept as given; an empty // string in a non-text column is null, as SendIt reads an empty cell. export function storedValue(field: ValueField, value: unknown): unknown { if (value === undefined || value === null) return value; const { type } = field; const textual = type === 'STRING' || type === 'ENUM' || type === 'RAW'; if (!textual && value === '') return null; if (type === 'BIGDECIMAL') return finite(decimalText(value, field.scale)) ?? value; if (DATE_TYPES.has(type)) return epochMillis(value) ?? value; if (typeof value !== 'string') return value; // An integer beyond 2^53 has no exact number: it stays the string it was. if (INTEGER_TYPES.has(type)) { const number = INTEGER.test(value.trim()) ? Number(value) : undefined; return number !== undefined && Number.isSafeInteger(number) ? number : value; } if (type === 'DOUBLE') { return NUMERIC.test(value.trim()) ? (finite(value.trim()) ?? value) : value; } if (type === 'BOOLEAN') { const lower = value.trim().toLowerCase(); return lower === 'true' ? true : lower === 'false' ? false : value; } return value; } // An ENUM value its field doesn't list, or undefined. A field with no // values accepts anything. export function unlistedEnumValue(field: ValueField, value: unknown): unknown { if (field.type !== 'ENUM' || !field.values?.length) return undefined; if (value === undefined || value === null) return undefined; return field.values.includes(value as string) ? undefined : value; } // An event's BigDecimal DETAILS as the handler gets them: a number, as GSF // deserializes the schema's string into a BigDecimal (not rounded: the // column's scale applies when it is written). export function eventValue(field: ValueField, value: unknown): unknown { if (field.type !== 'BIGDECIMAL' || typeof value !== 'string') return value; return finite(decimalText(value)) ?? value; } // A row as it goes on the wire: each BIGDECIMAL a string with its scale. The // row itself when nothing changes. export function encodeRow(row: Record, fields: ValueField[]): Record { let encoded: Record | undefined; for (const field of fields) { if (field.type !== 'BIGDECIMAL') continue; const value = row[field.name]; if (value === undefined || value === null) continue; const text = decimalText(value, field.scale); if (text === undefined || text === value) continue; encoded ??= { ...row }; encoded[field.name] = text; } return encoded ?? row; } // The BIGDECIMAL fields among these, for encodeRow; undefined when there are // none, so a caller can skip encoding altogether. export function bigDecimalFields(fields: ValueField[]): ValueField[] | undefined { const found = fields.filter((field) => field.type === 'BIGDECIMAL'); return found.length > 0 ? found : undefined; }