UNPKG

@electric-sql/client

Version:

Postgres everywhere - your data, in sync, wherever you need it.

230 lines (207 loc) 7.12 kB
import { ColumnInfo, GetExtensions, Row, Schema, Value } from './types' import { ParserNullValueError } from './error' type Token = string type NullableToken = Token | null export type ParseFunction<Extensions = never> = ( value: Token, additionalInfo?: Omit<ColumnInfo, `type` | `dims`> ) => Value<Extensions> type NullableParseFunction<Extensions = never> = ( value: NullableToken, additionalInfo?: Omit<ColumnInfo, `type` | `dims`> ) => Value<Extensions> /** * @typeParam Extensions - Additional types that can be parsed by this parser beyond the standard SQL types. * Defaults to no additional types. */ export type Parser<Extensions = never> = { [key: string]: ParseFunction<Extensions> } export type TransformFunction<Extensions = never> = ( message: Row<Extensions> ) => Row<Extensions> const parseNumber = (value: string) => Number(value) const parseBool = (value: string) => value === `true` || value === `t` const parseBigInt = (value: string) => BigInt(value) const parseJson = (value: string) => JSON.parse(value) const identityParser: ParseFunction = (v: string) => v export const defaultParser: Parser = { int2: parseNumber, int4: parseNumber, int8: parseBigInt, bool: parseBool, float4: parseNumber, float8: parseNumber, json: parseJson, jsonb: parseJson, } // Taken from: https://github.com/electric-sql/pglite/blob/main/packages/pglite/src/types.ts#L233-L279 export function pgArrayParser<Extensions>( value: Token, parser?: NullableParseFunction<Extensions> ): Value<Extensions> { let i = 0 let char = null let str = `` let quoted = false let last = 0 let p: string | undefined = undefined function extractValue(x: Token, start: number, end: number) { let val: Token | null = x.slice(start, end) val = val === `NULL` ? null : val return parser ? parser(val) : val } function loop(x: string): Array<Value<Extensions>> { const xs = [] for (; i < x.length; i++) { char = x[i] if (quoted) { if (char === `\\`) { str += x[++i] } else if (char === `"`) { xs.push(parser ? parser(str) : str) str = `` quoted = x[i + 1] === `"` last = i + 2 } else { str += char } } else if (char === `"`) { quoted = true } else if (char === `{`) { last = ++i xs.push(loop(x)) } else if (char === `}`) { quoted = false last < i && xs.push(extractValue(x, last, i)) last = i + 1 break } else if (char === `,` && p !== `}` && p !== `"`) { xs.push(extractValue(x, last, i)) last = i + 1 } p = char } last < i && xs.push(xs.push(extractValue(x, last, i + 1))) return xs } return loop(value)[0] } export class MessageParser<T extends Row<unknown>> { private parser: Parser<GetExtensions<T>> private transformer?: TransformFunction<GetExtensions<T>> constructor( parser?: Parser<GetExtensions<T>>, transformer?: TransformFunction<GetExtensions<T>> ) { // Merge the provided parser with the default parser // to use the provided parser whenever defined // and otherwise fall back to the default parser this.parser = { ...defaultParser, ...parser } this.transformer = transformer } parse<Result>(messages: string, schema: Schema): Result { return JSON.parse(messages, (key, value) => { // typeof value === `object` && value !== null // is needed because there could be a column named `value` // and the value associated to that column will be a string or null. // But `typeof null === 'object'` so we need to make an explicit check. // We also parse the `old_value`, which appears on updates when `replica=full`. if ( (key === `value` || key === `old_value`) && typeof value === `object` && value !== null ) { return this.transformMessageValue(value, schema) } return value }) as Result } /** * Parse an array of ChangeMessages from a snapshot response. * Applies type parsing and transformations to the value and old_value properties. */ parseSnapshotData<Result>( messages: Array<unknown>, schema: Schema ): Array<Result> { return messages.map((message) => { const msg = message as Record<string, unknown> // Transform the value property if it exists if (msg.value && typeof msg.value === `object` && msg.value !== null) { msg.value = this.transformMessageValue(msg.value, schema) } // Transform the old_value property if it exists if ( msg.old_value && typeof msg.old_value === `object` && msg.old_value !== null ) { msg.old_value = this.transformMessageValue(msg.old_value, schema) } return msg as Result }) } /** * Transform a message value or old_value object by parsing its columns. */ private transformMessageValue( value: unknown, schema: Schema ): Row<GetExtensions<T>> { const row = value as Record<string, Value<GetExtensions<T>>> Object.keys(row).forEach((key) => { row[key] = this.parseRow(key, row[key] as NullableToken, schema) }) return this.transformer ? this.transformer(row) : row } // Parses the message values using the provided parser based on the schema information private parseRow( key: string, value: NullableToken, schema: Schema ): Value<GetExtensions<T>> { const columnInfo = schema[key] if (!columnInfo) { // We don't have information about the value // so we just return it return value } // Copy the object but don't include `dimensions` and `type` const { type: typ, dims: dimensions, ...additionalInfo } = columnInfo // Pick the right parser for the type // and support parsing null values if needed // if no parser is provided for the given type, just return the value as is const typeParser = this.parser[typ] ?? identityParser const parser = makeNullableParser(typeParser, columnInfo, key) if (dimensions && dimensions > 0) { // It's an array const nullablePgArrayParser = makeNullableParser( (value, _) => pgArrayParser(value, parser), columnInfo, key ) return nullablePgArrayParser(value) } return parser(value, additionalInfo) } } function makeNullableParser<Extensions>( parser: ParseFunction<Extensions>, columnInfo: ColumnInfo, columnName?: string ): NullableParseFunction<Extensions> { const isNullable = !(columnInfo.not_null ?? false) // The sync service contains `null` value for a column whose value is NULL // but if the column value is an array that contains a NULL value // then it will be included in the array string as `NULL`, e.g.: `"{1,NULL,3}"` return (value: NullableToken) => { if (value === null) { if (!isNullable) { throw new ParserNullValueError(columnName ?? `unknown`) } return null } return parser(value, columnInfo) } }