Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom decoders - feature branch #458

Merged
merged 15 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion connection/connection_params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ export interface TLSOptions {
caCertificates: string[];
}

export type DecodeStrategy = "string" | "auto";
export type Decoders = Record<number, DecoderFunction>;
/**
* A decoder function that takes a string and returns a parsed value of some type
*/
export type DecoderFunction = (value: string) => unknown;

/**
* Control the behavior for the client instance
*/
Expand All @@ -107,7 +114,24 @@ 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
*/
decode_strategy?: "string" | "auto";
decode_strategy?: DecodeStrategy;
bombillazo marked this conversation as resolved.
Show resolved Hide resolved

/**
* A dictionary of functions used to decode (parse) column field values from string to a custom type. These functions will
* take presedence over the `decode_strategy`. Each key in the dictionary is the column OID type number and the value is
bombillazo marked this conversation as resolved.
Show resolved Hide resolved
* the decoder function. You can use the `Oid` object to set the decoder functions.
*
* @example
* {
* // 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)),
* }
bombillazo marked this conversation as resolved.
Show resolved Hide resolved
*/
decoders?: Decoders;
};

/** The Client database connection options */
Expand Down
11 changes: 8 additions & 3 deletions query/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
decodeTid,
decodeTidArray,
} from "./decoders.ts";
import { ClientControls } from "../connection/connection_params.ts";
import { ClientControls, Decoders } from "../connection/connection_params.ts";

export class Column {
constructor(
Expand All @@ -62,10 +62,15 @@ function decodeBinary() {
throw new Error("Decoding binary data is not implemented!");
}

function decodeText(value: Uint8Array, typeOid: number) {
function decodeText(value: Uint8Array, typeOid: number, decoders?: Decoders) {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
const strValue = decoder.decode(value);

try {
// If the user has specified a custom decoder, use that
if (decoders?.[typeOid]) {
return decoders[typeOid](strValue);
}

switch (typeOid) {
case Oid.bpchar:
case Oid.char:
Expand Down Expand Up @@ -222,7 +227,7 @@ export function decode(
return decoder.decode(value);
}
// default to 'auto' mode, which uses the typeOid to determine the decoding strategy
return decodeText(value, column.typeOid);
return decodeText(value, column.typeOid, controls?.decoders);
} else {
throw new Error(`Unknown column format: ${column.format}`);
}
Expand Down
90 changes: 86 additions & 4 deletions tests/query_client_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
Expand Down Expand Up @@ -119,15 +120,21 @@ 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, [
{
_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",
},
]);
Expand All @@ -141,15 +148,21 @@ 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, [
{
_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",
},
]);
Expand All @@ -158,6 +171,75 @@ 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" }),
// convert to date and add 2 days
[Oid.date]: (value: string) => {
const d = new Date(value);
return new Date(d.setDate(d.getDate() + 2));
},
// multiply by 100 - 5 = 785
[Oid.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
[Oid.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(),
// format timestamp into custom object
[Oid.timestamp]: (value: string) => {
const d = new Date(value);
return {
year: d.getFullYear() + 100,
month: `---${d.getMonth() + 1 < 10 ? "0" : ""}${
d.getMonth() + 1
}`,
};
},
},
},
},
),
);

Deno.test(
"Array arguments",
withClient(async (client) => {
Expand Down
Loading