From cadd9a144134ff8abef57d4bb11f277feea713d8 Mon Sep 17 00:00:00 2001 From: Hector Ayala Date: Sun, 11 Feb 2024 22:41:40 -0400 Subject: [PATCH] Custom decoders (#461) * feate: add encoding strategy control * chore: add encoding strategy tests * chore: fix file formatting * chore: fix lint issue of unused import * feat: add custom parsers * chore: fix lint issues * chore: fix docs issue * chore: move custom decoder function inside try catch * chore: update code comments * chore: fix variable anem to make camelcase * chore: add Oid related types and rever Oid map to avoid iteration and keep performance * chore: update decoder tests to check type name, add presedence test * chore: update decoder types, update decode logic to check for custom decoders and strategy * chore: fix lint issue for const variable * docs: update code commetns and create documentation for results decoding * chore: update mode exports, fix jsdocs lint * chore: fix file formats --- README.md | 4 +- connection/connection_params.ts | 40 ++++++- docs/README.md | 178 ++++++++++++++++++++++-------- docs/index.html | 2 +- mod.ts | 4 + query/decode.ts | 102 ++++++++++-------- query/oid.ts | 184 +++++++++++++++++++++++++++++++- tests/query_client_test.ts | 134 ++++++++++++++++++++++- 8 files changed, 551 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 0de12833..d7e1adea 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ discuss bugs and features before opening issues. it to run the linter and formatter locally - https://deno.land/ - - `deno upgrade --version 1.7.1` - - `dvm install 1.7.1 && dvm use 1.7.1` + - `deno upgrade --version 1.40.0` + - `dvm install 1.40.0 && dvm use 1.40.0` - You don't need to install Postgres locally on your machine to test the library, it will run as a service in the Docker container when you build it diff --git a/connection/connection_params.ts b/connection/connection_params.ts index bf006a21..ec4d07eb 100644 --- a/connection/connection_params.ts +++ b/connection/connection_params.ts @@ -1,6 +1,7 @@ import { parseConnectionUri } from "../utils/utils.ts"; import { ConnectionParamsError } from "../client/error.ts"; import { fromFileUrl, isAbsolute } from "../deps.ts"; +import { OidKey } from "../query/oid.ts"; /** * The connection string must match the following URI structure. All parameters but database and user are optional @@ -91,12 +92,23 @@ export interface TLSOptions { caCertificates: string[]; } +export type DecodeStrategy = "string" | "auto"; +export type Decoders = { + [key in number | OidKey]?: DecoderFunction; +}; + +/** + * A decoder function that takes a string value and returns a parsed value of some type. + * the Oid is also passed to the function for reference + */ +export type DecoderFunction = (value: string, oid: number) => unknown; + /** * Control the behavior for the client instance */ export type ClientControls = { /** - * The strategy to use when decoding binary fields + * The strategy to use when decoding results data * * `string` : all values are returned as string, and the user has to take care of parsing * `auto` : deno-postgres parses the data into JS objects (as many as possible implemented, non-implemented parsers would still return strings) @@ -107,7 +119,31 @@ export type ClientControls = { * - `strict` : deno-postgres parses the data into JS objects, and if a parser is not implemented, it throws an error * - `raw` : the data is returned as Uint8Array */ - decodeStrategy?: "string" | "auto"; + decodeStrategy?: DecodeStrategy; + + /** + * A dictionary of functions used to decode (parse) column field values from string to a custom type. These functions will + * take precedence over the {@linkcode ClientControls.decodeStrategy}. Each key in the dictionary is the column OID type number, and the value is + * the decoder function. You can use the `Oid` object to set the decoder functions. + * + * @example + * ```ts + * import dayjs from 'https://esm.sh/dayjs'; + * import { Oid,Decoders } from '../mod.ts' + * + * { + * const decoders: Decoders = { + * // 16 = Oid.bool : convert all boolean values to numbers + * '16': (value: string) => value === 't' ? 1 : 0, + * // 1082 = Oid.date : convert all dates to dayjs objects + * 1082: (value: string) => dayjs(value), + * // 23 = Oid.int4 : convert all integers to positive numbers + * [Oid.int4]: (value: string) => Math.max(0, parseInt(value || '0', 10)), + * } + * } + * ``` + */ + decoders?: Decoders; }; /** The Client database connection options */ diff --git a/docs/README.md b/docs/README.md index 11eb512e..9b90bbff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,7 +53,7 @@ config = { host_type: "tcp", password: "password", options: { - "max_index_keys": "32", + max_index_keys: "32", }, port: 5432, user: "user", @@ -96,7 +96,7 @@ parsing the options in your connection string as if it was an options object You can create your own connection string by using the following structure: -``` +```txt driver://user:password@host:port/database_name driver://host:port/database_name?user=user&password=password&application_name=my_app @@ -126,6 +126,7 @@ of search parameters such as the following: - prefer: Attempt to stablish a TLS connection, default to unencrypted if the negotiation fails - disable: Skip TLS connection altogether + - user: If user is not specified in the url, this will be taken instead #### Password encoding @@ -438,13 +439,16 @@ For stronger management and scalability, you can use **pools**: ```ts const POOL_CONNECTIONS = 20; -const dbPool = new Pool({ - database: "database", - hostname: "hostname", - password: "password", - port: 5432, - user: "user", -}, POOL_CONNECTIONS); +const dbPool = new Pool( + { + database: "database", + hostname: "hostname", + password: "password", + port: 5432, + user: "user", + }, + POOL_CONNECTIONS, +); const client = await dbPool.connect(); // 19 connections are still available await client.queryArray`UPDATE X SET Y = 'Z'`; @@ -690,6 +694,101 @@ await client .queryArray`DELETE FROM ${my_table} WHERE MY_COLUMN = ${my_other_id};`; ``` +### Result decoding + +When a query is executed, the database returns all the data serialized as string +values. The `deno-postgres` driver automatically takes care of decoding the +results data of your query into the closest JavaScript compatible data type. +This makes it easy to work with the data in your applciation using native +Javascript types. A list of implemented type parsers can be found +[here](https://github.com/denodrivers/postgres/issues/446). + +However, you may have more specific needs or may want to handle decoding +yourself in your application. The driver provides 2 ways to handle decoding of +the result data: + +#### Decode strategy + +You can provide a global decode strategy to the client that will be used to +decode the result data. This can be done by setting the `decodeStrategy` +controls option when creating your query client. The following options are +available: + +- `auto` : (**default**) deno-postgres parses the data into JS types or objects + (non-implemented type parsers would still return strings). +- `string` : all values are returned as string, and the user has to take care of + parsing + +```ts +{ + // Will return all values parsed to native types + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "auto", // or not setting it at all + }, + }); + + const result = await client.queryArray( + "SELECT ID, NAME, AGE, BIRTHDATE FROM PEOPLE WHERE ID = 1", + ); + console.log(result.rows); // [[1, "Laura", 25, 1996-01-01T00:00:00.000Z ]] + + // versus + + // Will return all values as strings + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "string", + }, + }); + + const result = await client.queryArray( + "SELECT ID, NAME, AGE, BIRTHDATE FROM PEOPLE WHERE ID = 1", + ); + console.log(result.rows); // [["1", "Laura", "25", "1996-01-01"]] +} +``` + +#### Custom decoders + +You can also provide custom decoders to the client that will be used to decode +the result data. This can be done by setting the `decoders` controls option in +the client configuration. This options is a map object where the keys are the +type names or Oid number and the values are the custom decoder functions. + +You can use it with the decode strategy. Custom decoders take precedence over +the strategy and internal parsers. + +```ts +{ + // Will return all values as strings, but custom decoders will take precedence + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "string", + decoders: { + // Custom decoder for boolean + // for some reason, return booleans as an object with a type and value + bool: (value: string) => ({ + value: value === "t", + type: "boolean", + }), + }, + }, + }); + + const result = await client.queryObject( + "SELECT ID, NAME, IS_ACTIVE FROM PEOPLE", + ); + console.log(result.rows[0]); // {id: '1', name: 'Javier', _bool: { value: false, type: "boolean"}} +} +``` + ### Specifying result type Both the `queryArray` and `queryObject` functions have a generic implementation @@ -722,9 +821,10 @@ intellisense } { - const object_result = await client.queryObject< - { id: number; name: string } - >`SELECT ID, NAME FROM PEOPLE WHERE ID = ${17}`; + const object_result = await client.queryObject<{ + id: number; + name: string; + }>`SELECT ID, NAME FROM PEOPLE WHERE ID = ${17}`; // {id: number, name: string} const person = object_result.rows[0]; } @@ -741,9 +841,7 @@ interface User { name: string; } -const result = await client.queryObject( - "SELECT ID, NAME FROM PEOPLE", -); +const result = await client.queryObject("SELECT ID, NAME FROM PEOPLE"); // User[] const users = result.rows; @@ -791,12 +889,10 @@ To deal with this issue, it's recommended to provide a field list that maps to the expected properties we want in the resulting object ```ts -const result = await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "name"], - }, -); +const result = await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "name"], +}); const users = result.rows; // [{id: 1, name: 'Ca'}, {id: 2, name: 'Jo'}, ...] ``` @@ -833,23 +929,19 @@ Other aspects to take into account when using the `fields` argument: ```ts { // This will throw because the property id is duplicated - await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "ID"], - }, - ); + await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "ID"], + }); } { // This will throw because the returned number of columns don't match the // number of defined ones in the function call - await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "name", "something_else"], - }, - ); + await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "name", "something_else"], + }); } ``` @@ -1078,6 +1170,7 @@ following levels of transaction isolation: - Repeatable read: This isolates the transaction in a way that any external changes to the data we are reading won't be visible inside the transaction until it has finished + ```ts const client_1 = await pool.connect(); const client_2 = await pool.connect(); @@ -1089,18 +1182,18 @@ following levels of transaction isolation: await transaction.begin(); // This locks the current value of IMPORTANT_TABLE // Up to this point, all other external changes will be included - const { rows: query_1 } = await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + const { rows: query_1 } = await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; const password_1 = rows[0].password; // Concurrent operation executed by a different user in a different part of the code await client_2 .queryArray`UPDATE IMPORTANT_TABLE SET PASSWORD = 'something_else' WHERE ID = ${the_same_id}`; - const { rows: query_2 } = await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + const { rows: query_2 } = await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; const password_2 = rows[0].password; // Database state is not updated while the transaction is ongoing @@ -1117,6 +1210,7 @@ following levels of transaction isolation: be visible until the transaction has finished. However this also prevents the current transaction from making persistent changes if the data they were reading at the beginning of the transaction has been modified (recommended) + ```ts const client_1 = await pool.connect(); const client_2 = await pool.connect(); @@ -1128,9 +1222,9 @@ following levels of transaction isolation: await transaction.begin(); // This locks the current value of IMPORTANT_TABLE // Up to this point, all other external changes will be included - await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; // Concurrent operation executed by a different user in a different part of the code await client_2 diff --git a/docs/index.html b/docs/index.html index 066d193f..4ce33e9f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Deno Postgres - + diff --git a/mod.ts b/mod.ts index b0bac8ac..143abffc 100644 --- a/mod.ts +++ b/mod.ts @@ -5,14 +5,18 @@ export { TransactionError, } from "./client/error.ts"; export { Pool } from "./pool.ts"; +export { Oid, OidTypes } from "./query/oid.ts"; // TODO // Remove the following reexports after https://doc.deno.land // supports two level depth exports +export type { OidKey, OidType } from "./query/oid.ts"; export type { ClientOptions, ConnectionOptions, ConnectionString, + Decoders, + DecodeStrategy, TLSOptions, } from "./connection/connection_params.ts"; export type { Session } from "./client.ts"; diff --git a/query/decode.ts b/query/decode.ts index 1afe82a0..2904567d 100644 --- a/query/decode.ts +++ b/query/decode.ts @@ -1,4 +1,4 @@ -import { Oid } from "./oid.ts"; +import { Oid, OidType, OidTypes } from "./oid.ts"; import { bold, yellow } from "../deps.ts"; import { decodeBigint, @@ -62,9 +62,7 @@ function decodeBinary() { throw new Error("Decoding binary data is not implemented!"); } -function decodeText(value: Uint8Array, typeOid: number) { - const strValue = decoder.decode(value); - +function decodeText(value: string, typeOid: number) { try { switch (typeOid) { case Oid.bpchar: @@ -92,7 +90,7 @@ function decodeText(value: Uint8Array, typeOid: number) { case Oid.uuid: case Oid.varchar: case Oid.void: - return strValue; + return value; case Oid.bpchar_array: case Oid.char_array: case Oid.cidr_array: @@ -117,85 +115,85 @@ function decodeText(value: Uint8Array, typeOid: number) { case Oid.timetz_array: case Oid.uuid_array: case Oid.varchar_array: - return decodeStringArray(strValue); + return decodeStringArray(value); case Oid.float4: - return decodeFloat(strValue); + return decodeFloat(value); case Oid.float4_array: - return decodeFloatArray(strValue); + return decodeFloatArray(value); case Oid.int2: case Oid.int4: case Oid.xid: - return decodeInt(strValue); + return decodeInt(value); case Oid.int2_array: case Oid.int4_array: case Oid.xid_array: - return decodeIntArray(strValue); + return decodeIntArray(value); case Oid.bool: - return decodeBoolean(strValue); + return decodeBoolean(value); case Oid.bool_array: - return decodeBooleanArray(strValue); + return decodeBooleanArray(value); case Oid.box: - return decodeBox(strValue); + return decodeBox(value); case Oid.box_array: - return decodeBoxArray(strValue); + return decodeBoxArray(value); case Oid.circle: - return decodeCircle(strValue); + return decodeCircle(value); case Oid.circle_array: - return decodeCircleArray(strValue); + return decodeCircleArray(value); case Oid.bytea: - return decodeBytea(strValue); + return decodeBytea(value); case Oid.byte_array: - return decodeByteaArray(strValue); + return decodeByteaArray(value); case Oid.date: - return decodeDate(strValue); + return decodeDate(value); case Oid.date_array: - return decodeDateArray(strValue); + return decodeDateArray(value); case Oid.int8: - return decodeBigint(strValue); + return decodeBigint(value); case Oid.int8_array: - return decodeBigintArray(strValue); + return decodeBigintArray(value); case Oid.json: case Oid.jsonb: - return decodeJson(strValue); + return decodeJson(value); case Oid.json_array: case Oid.jsonb_array: - return decodeJsonArray(strValue); + return decodeJsonArray(value); case Oid.line: - return decodeLine(strValue); + return decodeLine(value); case Oid.line_array: - return decodeLineArray(strValue); + return decodeLineArray(value); case Oid.lseg: - return decodeLineSegment(strValue); + return decodeLineSegment(value); case Oid.lseg_array: - return decodeLineSegmentArray(strValue); + return decodeLineSegmentArray(value); case Oid.path: - return decodePath(strValue); + return decodePath(value); case Oid.path_array: - return decodePathArray(strValue); + return decodePathArray(value); case Oid.point: - return decodePoint(strValue); + return decodePoint(value); case Oid.point_array: - return decodePointArray(strValue); + return decodePointArray(value); case Oid.polygon: - return decodePolygon(strValue); + return decodePolygon(value); case Oid.polygon_array: - return decodePolygonArray(strValue); + return decodePolygonArray(value); case Oid.tid: - return decodeTid(strValue); + return decodeTid(value); case Oid.tid_array: - return decodeTidArray(strValue); + return decodeTidArray(value); case Oid.timestamp: case Oid.timestamptz: - return decodeDatetime(strValue); + return decodeDatetime(value); case Oid.timestamp_array: case Oid.timestamptz_array: - return decodeDatetimeArray(strValue); + return decodeDatetimeArray(value); default: // A separate category for not handled values // They might or might not be represented correctly as strings, // returning them to the user as raw strings allows them to parse // them as they see fit - return strValue; + return value; } } catch (_e) { console.error( @@ -214,15 +212,29 @@ export function decode( column: Column, controls?: ClientControls, ) { + const strValue = decoder.decode(value); + + // check if there is a custom decoder + if (controls?.decoders) { + // check if there is a custom decoder by oid (number) or by type name (string) + const decoderFunc = controls.decoders?.[column.typeOid] || + controls.decoders?.[OidTypes[column.typeOid as OidType]]; + + if (decoderFunc) { + return decoderFunc(strValue, column.typeOid); + } + } + + // check if the decode strategy is `string` + if (controls?.decodeStrategy === "string") { + return strValue; + } + + // else, default to 'auto' mode, which uses the typeOid to determine the decoding strategy if (column.format === Format.BINARY) { return decodeBinary(); } else if (column.format === Format.TEXT) { - // If the user has specified a decode strategy, use that - if (controls?.decodeStrategy === "string") { - return decoder.decode(value); - } - // default to 'auto' mode, which uses the typeOid to determine the decoding strategy - return decodeText(value, column.typeOid); + return decodeText(strValue, column.typeOid); } else { throw new Error(`Unknown column format: ${column.format}`); } diff --git a/query/oid.ts b/query/oid.ts index 29fc63e5..9b36c88b 100644 --- a/query/oid.ts +++ b/query/oid.ts @@ -1,3 +1,9 @@ +export type OidKey = keyof typeof Oid; +export type OidType = (typeof Oid)[OidKey]; + +/** + * Oid is a map of OidKey to OidType. + */ export const Oid = { bool: 16, bytea: 17, @@ -166,4 +172,180 @@ export const Oid = { regnamespace_array: 4090, regrole: 4096, regrole_array: 4097, -}; +} as const; + +/** + * OidTypes is a map of OidType to OidKey. + * Used to decode values and avoid search iteration + */ +export const OidTypes: { + [key in OidType]: OidKey; +} = { + 16: "bool", + 17: "bytea", + 18: "char", + 19: "name", + 20: "int8", + 21: "int2", + 22: "_int2vector_0", + 23: "int4", + 24: "regproc", + 25: "text", + 26: "oid", + 27: "tid", + 28: "xid", + 29: "_cid_0", + 30: "_oidvector_0", + 32: "_pg_ddl_command", + 71: "_pg_type", + 75: "_pg_attribute", + 81: "_pg_proc", + 83: "_pg_class", + 114: "json", + 142: "_xml_0", + 143: "_xml_1", + 194: "_pg_node_tree", + 199: "json_array", + 210: "_smgr", + 325: "_index_am_handler", + 600: "point", + 601: "lseg", + 602: "path", + 603: "box", + 604: "polygon", + 628: "line", + 629: "line_array", + 650: "cidr", + 651: "cidr_array", + 700: "float4", + 701: "float8", + 702: "_abstime_0", + 703: "_reltime_0", + 704: "_tinterval_0", + 705: "_unknown", + 718: "circle", + 719: "circle_array", + 790: "_money_0", + 791: "_money_1", + 829: "macaddr", + 869: "inet", + 1000: "bool_array", + 1001: "byte_array", + 1002: "char_array", + 1003: "name_array", + 1005: "int2_array", + 1006: "_int2vector_1", + 1007: "int4_array", + 1008: "regproc_array", + 1009: "text_array", + 1010: "tid_array", + 1011: "xid_array", + 1012: "_cid_1", + 1013: "_oidvector_1", + 1014: "bpchar_array", + 1015: "varchar_array", + 1016: "int8_array", + 1017: "point_array", + 1018: "lseg_array", + 1019: "path_array", + 1020: "box_array", + 1021: "float4_array", + 1022: "float8_array", + 1023: "_abstime_1", + 1024: "_reltime_1", + 1025: "_tinterval_1", + 1027: "polygon_array", + 1028: "oid_array", + 1033: "_aclitem_0", + 1034: "_aclitem_1", + 1040: "macaddr_array", + 1041: "inet_array", + 1042: "bpchar", + 1043: "varchar", + 1082: "date", + 1083: "time", + 1114: "timestamp", + 1115: "timestamp_array", + 1182: "date_array", + 1183: "time_array", + 1184: "timestamptz", + 1185: "timestamptz_array", + 1186: "_interval_0", + 1187: "_interval_1", + 1231: "numeric_array", + 1248: "_pg_database", + 1263: "_cstring_0", + 1266: "timetz", + 1270: "timetz_array", + 1560: "_bit_0", + 1561: "_bit_1", + 1562: "_varbit_0", + 1563: "_varbit_1", + 1700: "numeric", + 1790: "_refcursor_0", + 2201: "_refcursor_1", + 2202: "regprocedure", + 2203: "regoper", + 2204: "regoperator", + 2205: "regclass", + 2206: "regtype", + 2207: "regprocedure_array", + 2208: "regoper_array", + 2209: "regoperator_array", + 2210: "regclass_array", + 2211: "regtype_array", + 2249: "_record_0", + 2275: "_cstring_1", + 2276: "_any", + 2277: "_anyarray", + 2278: "void", + 2279: "_trigger", + 2280: "_language_handler", + 2281: "_internal", + 2282: "_opaque", + 2283: "_anyelement", + 2287: "_record_1", + 2776: "_anynonarray", + 2842: "_pg_authid", + 2843: "_pg_auth_members", + 2949: "_txid_snapshot_0", + 2950: "uuid", + 2951: "uuid_array", + 2970: "_txid_snapshot_1", + 3115: "_fdw_handler", + 3220: "_pg_lsn_0", + 3221: "_pg_lsn_1", + 3310: "_tsm_handler", + 3500: "_anyenum", + 3614: "_tsvector_0", + 3615: "_tsquery_0", + 3642: "_gtsvector_0", + 3643: "_tsvector_1", + 3644: "_gtsvector_1", + 3645: "_tsquery_1", + 3734: "regconfig", + 3735: "regconfig_array", + 3769: "regdictionary", + 3770: "regdictionary_array", + 3802: "jsonb", + 3807: "jsonb_array", + 3831: "_anyrange", + 3838: "_event_trigger", + 3904: "_int4range_0", + 3905: "_int4range_1", + 3906: "_numrange_0", + 3907: "_numrange_1", + 3908: "_tsrange_0", + 3909: "_tsrange_1", + 3910: "_tstzrange_0", + 3911: "_tstzrange_1", + 3912: "_daterange_0", + 3913: "_daterange_1", + 3926: "_int8range_0", + 3927: "_int8range_1", + 4066: "_pg_shseclabel", + 4089: "regnamespace", + 4090: "regnamespace_array", + 4096: "regrole", + 4097: "regrole_array", +} as const; diff --git a/tests/query_client_test.ts b/tests/query_client_test.ts index 4c4217bf..84e05f94 100644 --- a/tests/query_client_test.ts +++ b/tests/query_client_test.ts @@ -15,6 +15,7 @@ import { import { getMainConfiguration } from "./config.ts"; import { PoolClient, QueryClient } from "../client.ts"; import { ClientOptions } from "../connection/connection_params.ts"; +import { Oid } from "../query/oid.ts"; function withClient( t: (client: QueryClient) => void | Promise, @@ -119,7 +120,13 @@ Deno.test( withClient( async (client) => { const result = await client.queryObject( - `SELECT ARRAY[1, 2, 3] AS _int_array, 3.14::REAL AS _float, 'DATA' AS _text, '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _json, 'Y'::BOOLEAN AS _bool`, + `SELECT + 'Y'::BOOLEAN AS _bool, + 3.14::REAL AS _float, + ARRAY[1, 2, 3] AS _int_array, + '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _jsonb, + 'DATA' AS _text + ;`, ); assertEquals(result.rows, [ @@ -127,7 +134,7 @@ Deno.test( _bool: true, _float: 3.14, _int_array: [1, 2, 3], - _json: { test: "foo", arr: [1, 2, 3] }, + _jsonb: { test: "foo", arr: [1, 2, 3] }, _text: "DATA", }, ]); @@ -141,7 +148,13 @@ Deno.test( withClient( async (client) => { const result = await client.queryObject( - `SELECT ARRAY[1, 2, 3] AS _int_array, 3.14::REAL AS _float, 'DATA' AS _text, '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _json, 'Y'::BOOLEAN AS _bool`, + `SELECT + 'Y'::BOOLEAN AS _bool, + 3.14::REAL AS _float, + ARRAY[1, 2, 3] AS _int_array, + '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _jsonb, + 'DATA' AS _text + ;`, ); assertEquals(result.rows, [ @@ -149,7 +162,7 @@ Deno.test( _bool: "t", _float: "3.14", _int_array: "{1,2,3}", - _json: '{"arr": [1, 2, 3], "test": "foo"}', + _jsonb: '{"arr": [1, 2, 3], "test": "foo"}', _text: "DATA", }, ]); @@ -158,6 +171,119 @@ Deno.test( ), ); +Deno.test( + "Custom decoders", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + 0::BOOLEAN AS _bool, + (DATE '2024-01-01' + INTERVAL '2 months')::DATE AS _date, + 7.90::REAL AS _float, + 100 AS _int, + '{"foo": "a", "bar": [1,2,3], "baz": null}'::JSONB AS _jsonb, + 'MY_VALUE' AS _text, + DATE '2024-10-01' + INTERVAL '2 years' - INTERVAL '2 months' AS _timestamp + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: { boolean: false }, + _date: new Date("2024-03-03T00:00:00.000Z"), + _float: 785, + _int: 200, + _jsonb: { id: "999", foo: "A", bar: [2, 4, 6], baz: "initial" }, + _text: ["E", "U", "L", "A", "V", "_", "Y", "M"], + _timestamp: { year: 2126, month: "---08" }, + }, + ]); + }, + { + controls: { + decoders: { + // convert to object + [Oid.bool]: (value: string) => ({ boolean: value === "t" }), + // 1082 = date : convert to date and add 2 days + "1082": (value: string) => { + const d = new Date(value); + return new Date(d.setDate(d.getDate() + 2)); + }, + // multiply by 100 - 5 = 785 + float4: (value: string) => parseFloat(value) * 100 - 5, + // convert to int and add 100 = 200 + [Oid.int4]: (value: string) => parseInt(value, 10) + 100, + // parse with multiple conditions + jsonb: (value: string) => { + const obj = JSON.parse(value); + obj.foo = obj.foo.toUpperCase(); + obj.id = "999"; + obj.bar = obj.bar.map((v: number) => v * 2); + if (obj.baz === null) obj.baz = "initial"; + return obj; + }, + // split string and reverse + [Oid.text]: (value: string) => value.split("").reverse(), + // 1114 = timestamp : format timestamp into custom object + 1114: (value: string) => { + const d = new Date(value); + return { + year: d.getFullYear() + 100, + month: `---${d.getMonth() + 1 < 10 ? "0" : ""}${ + d.getMonth() + 1 + }`, + }; + }, + }, + }, + }, + ), +); + +Deno.test( + "Custom decoder precedence", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + 0::BOOLEAN AS _bool, + 1 AS _int, + 1::REAL AS _float, + 'TEST' AS _text + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: "success", + _float: "success", + _int: "success", + _text: "success", + }, + ]); + }, + { + controls: { + // numeric oid type values take precedence over name + decoders: { + // bool + bool: () => "fail", + [16]: () => "success", + //int + int4: () => "fail", + [Oid.int4]: () => "success", + // float4 + float4: () => "fail", + "700": () => "success", + // text + text: () => "fail", + 25: () => "success", + }, + }, + }, + ), +); + Deno.test( "Array arguments", withClient(async (client) => {