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

Client config for boolean casting #15

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
71 changes: 64 additions & 7 deletions src/__tests__/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ checkEndpoints.push({
encoding: "json",
});

function withClient(f: (c: hrana.Client) => Promise<void>): () => Promise<void> {
function withClient(f: (c: hrana.Client) => Promise<void>, config?: hrana.ClientConfig): () => Promise<void> {
return async () => {
let client: hrana.Client;
if (isWs) {
client = hrana.openWs(url, jwt, 3);
client = hrana.openWs(url, jwt, 3, config);
} else if (isHttp) {
client = hrana.openHttp(url, jwt, undefined, 3);
client = hrana.openHttp(url, jwt, undefined, 3, config);
} else {
throw new Error("expected either ws or http URL");
}
Expand Down Expand Up @@ -82,7 +82,7 @@ test("Stream.queryValue() without value", withClient(async (c) => {

test("Stream.queryRow() with row", withClient(async (c) => {
const s = c.openStream();

const res = await s.queryRow(
"SELECT 1 AS one, 'elephant' AS two, 42.5 AS three, NULL as four");
expect(res.columnNames).toStrictEqual(["one", "two", "three", "four"]);
Expand All @@ -102,7 +102,7 @@ test("Stream.queryRow() with row", withClient(async (c) => {

test("Stream.queryRow() without row", withClient(async (c) => {
const s = c.openStream();

const res = await s.queryValue("SELECT 1 AS one WHERE 0 = 1");
expect(res.value).toStrictEqual(undefined);
expect(res.columnNames).toStrictEqual(["one"]);
Expand Down Expand Up @@ -318,6 +318,63 @@ describe("returned integers", () => {
});
});

describe("returned booleans", () => {
const columnName = 'isActive';
describe("booleans are JS integers", () => {
test('without config', withClient(async (c) => {
const s = c.openStream();
await s.run("BEGIN");
await s.run("DROP TABLE IF EXISTS t");
await s.run(`CREATE TABLE t (id INTEGER PRIMARY KEY, ${columnName} BOOLEAN)`);
await s.run("INSERT INTO t VALUES (1, true)");
await s.run("INSERT INTO t VALUES (2, false)");
await s.run("COMMIT");

const resTrue = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 1`);
const valTrue = resTrue.row?.[columnName];
expect(typeof valTrue).toStrictEqual("number");
expect(valTrue).toStrictEqual(1);

const resFalse = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 2`);
const valFalse = resFalse.row?.[columnName];
expect(typeof valFalse).toStrictEqual("number");
expect(valFalse).toStrictEqual(0);
}));

test('with config', withClient(async (c) => {
const s = c.openStream();
const resTrue = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 1`);
const valTrue = resTrue.row?.[columnName];
expect(typeof valTrue).toStrictEqual("number");
expect(valTrue).toStrictEqual(1);

const resFalse = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 2`);
const valFalse = resFalse.row?.[columnName];
expect(typeof valFalse).toStrictEqual("number");
expect(valFalse).toStrictEqual(0);
}, { castBooleans: false }));
});

describe("booleans are JS booleans", () => {
test('with config', withClient(async (c) => {
const s = c.openStream();
const resTrue = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 1`);
const valTrue = resTrue.row?.[columnName];
expect(typeof valTrue).toStrictEqual("boolean");
expect(valTrue).toStrictEqual(true);

const resFalse = await s.queryRow(`SELECT ${columnName} FROM t WHERE id = 2`);
const valFalse = resFalse.row?.[columnName];
expect(typeof valFalse).toStrictEqual("boolean");
expect(valFalse).toStrictEqual(false);

await s.run("BEGIN");
await s.run("DROP TABLE t");
await s.run("COMMIT");
}, { castBooleans: true }));
});
});

test("response error", withClient(async (c) => {
const s = c.openStream();
await expect(s.queryValue("SELECT")).rejects.toBeInstanceOf(hrana.ResponseError);
Expand Down Expand Up @@ -539,7 +596,7 @@ for (const useCursor of [false, true]) {
test("failing statement", withClient(async (c) => {
if (useCursor) { await c.getVersion(); }
const s = c.openStream();

const batch = s.batch(useCursor);
const prom1 = batch.step().queryValue("SELECT 1");
const prom2 = batch.step().queryValue("SELECT foobar");
Expand Down Expand Up @@ -904,7 +961,7 @@ for (const useCursor of [false, true]) {
for (const useCursor of [false, true]) {
(version >= 3 || !useCursor ? test : test.skip)(
useCursor ? "batch w/ cursor" : "batch w/o cursor",
withSqlOwner(async (s, owner) =>
withSqlOwner(async (s, owner) =>
{
const sql1 = owner.storeSql("SELECT 11");
const sql2 = owner.storeSql("SELECT 'one', 'two'");
Expand Down
13 changes: 6 additions & 7 deletions src/batch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ClientConfig } from "./client.js";
import { ProtoError, MisuseError } from "./errors.js";
import { IdAlloc } from "./id_alloc.js";
import type { RowsResult, RowResult, ValueResult, StmtResult } from "./result.js";
import {
stmtResultFromProto, rowsResultFromProto,
Expand All @@ -11,8 +11,7 @@ import type { InStmt } from "./stmt.js";
import { stmtToProto } from "./stmt.js";
import { Stream } from "./stream.js";
import { impossible } from "./util.js";
import type { Value, InValue, IntMode } from "./value.js";
import { valueToProto, valueFromProto } from "./value.js";
import type { IntMode } from "./value.js";

/** A builder for creating a batch and executing it on the server. */
export class Batch {
Expand Down Expand Up @@ -205,7 +204,7 @@ export class BatchStep {
#add<T>(
inStmt: InStmt,
wantRows: boolean,
fromProto: (result: proto.StmtResult, intMode: IntMode) => T,
fromProto: (result: proto.StmtResult, intMode: IntMode, config: ClientConfig) => T,
): Promise<T | undefined> {
if (this._index !== undefined) {
throw new MisuseError("This BatchStep has already been added to the batch");
Expand Down Expand Up @@ -234,7 +233,7 @@ export class BatchStep {
} else if (stepError !== undefined) {
errorCallback(errorFromProto(stepError));
} else if (stepResult !== undefined) {
outputCallback(fromProto(stepResult, this._batch._stream.intMode));
outputCallback(fromProto(stepResult, this._batch._stream.intMode, this._batch._stream.config));
} else {
outputCallback(undefined);
}
Expand Down Expand Up @@ -282,7 +281,7 @@ export class BatchCond {
return new BatchCond(cond._batch, {type: "not", cond: cond._proto});
}

/** Create a condition that is a logical AND of other conditions.
/** Create a condition that is a logical AND of other conditions.
*/
static and(batch: Batch, conds: Array<BatchCond>): BatchCond {
for (const cond of conds) {
Expand All @@ -291,7 +290,7 @@ export class BatchCond {
return new BatchCond(batch, {type: "and", conds: conds.map(e => e._proto)});
}

/** Create a condition that is a logical OR of other conditions.
/** Create a condition that is a logical OR of other conditions.
*/
static or(batch: Batch, conds: Array<BatchCond>): BatchCond {
for (const cond of conds) {
Expand Down
9 changes: 8 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import type { IntMode } from "./value.js";

export type ProtocolVersion = 1 | 2 | 3;
export type ProtocolEncoding = "json" | "protobuf";
export type ClientConfig = {
castBooleans?: boolean;
};

/** A client for the Hrana protocol (a "database connection pool"). */
export abstract class Client {
/** @private */
constructor() {
constructor(config: ClientConfig) {
this.config = config;
this.intMode = "number";
}

Expand Down Expand Up @@ -36,4 +40,7 @@ export abstract class Client {
* override the integer mode for every stream by setting {@link Stream.intMode} on the stream.
*/
intMode: IntMode;

/** Stores the client configuration. See {@link ClientConfig}. */
config: ClientConfig;
}
6 changes: 3 additions & 3 deletions src/http/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fetch, Request } from "@libsql/isomorphic-fetch";

import type { ProtocolVersion, ProtocolEncoding } from "../client.js";
import type { ProtocolVersion, ProtocolEncoding, ClientConfig } from "../client.js";
import { Client } from "../client.js";
import { ClientError, ClosedError, ProtocolVersionError } from "../errors.js";

Expand Down Expand Up @@ -56,8 +56,8 @@ export class HttpClient extends Client {
_endpoint: Endpoint | undefined;

/** @private */
constructor(url: URL, jwt: string | undefined, customFetch: unknown | undefined, protocolVersion: ProtocolVersion = 2) {
super();
constructor(url: URL, jwt: string | undefined, customFetch: unknown | undefined, protocolVersion: ProtocolVersion = 2, config: ClientConfig) {
super(config);
this.#url = url;
this.#jwt = jwt;
this.#fetch = (customFetch as typeof fetch) ?? fetch;
Expand Down
2 changes: 1 addition & 1 deletion src/http/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class HttpStream extends Stream implements SqlOwner {

/** @private */
constructor(client: HttpClient, baseUrl: URL, jwt: string | undefined, customFetch: typeof fetch) {
super(client.intMode);
super(client.intMode, client.config);
this.#client = client;
this.#baseUrl = baseUrl.toString();
this.#jwt = jwt;
Expand Down
12 changes: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { WebSocketUnsupportedError } from "./errors.js";

import { HttpClient } from "./http/client.js";
import { WsClient } from "./ws/client.js";
import { ProtocolVersion } from "./client.js";
import { ClientConfig, ProtocolVersion } from "./client.js";

export { WebSocket } from "@libsql/isomorphic-ws";
export type { RequestInit, Response } from "@libsql/isomorphic-fetch";
export { fetch, Request, Headers } from "@libsql/isomorphic-fetch";

export type { ProtocolVersion, ProtocolEncoding } from "./client.js";
export type { ClientConfig, ProtocolVersion, ProtocolEncoding } from "./client.js";
export { Client } from "./client.js";
export type { DescribeResult, DescribeColumn } from "./describe.js";
export * from "./errors.js";
Expand All @@ -32,7 +32,7 @@ export { WsClient } from "./ws/client.js";
export { WsStream } from "./ws/stream.js";

/** Open a Hrana client over WebSocket connected to the given `url`. */
export function openWs(url: string | URL, jwt?: string, protocolVersion: ProtocolVersion = 2): WsClient {
export function openWs(url: string | URL, jwt?: string, protocolVersion: ProtocolVersion = 2, config: ClientConfig = {}): WsClient {
if (typeof WebSocket === "undefined") {
throw new WebSocketUnsupportedError("WebSockets are not supported in this environment");
}
Expand All @@ -43,7 +43,7 @@ export function openWs(url: string | URL, jwt?: string, protocolVersion: Protoco
subprotocols = Array.from(subprotocolsV2.keys());
}
const socket = new WebSocket(url, subprotocols);
return new WsClient(socket, jwt);
return new WsClient(socket, jwt, config);
}

/** Open a Hrana client over HTTP connected to the given `url`.
Expand All @@ -52,6 +52,6 @@ export function openWs(url: string | URL, jwt?: string, protocolVersion: Protoco
* from `@libsql/isomorphic-fetch`. This function is always called with a `Request` object from
* `@libsql/isomorphic-fetch`.
*/
export function openHttp(url: string | URL, jwt?: string, customFetch?: unknown | undefined, protocolVersion: ProtocolVersion = 2): HttpClient {
return new HttpClient(url instanceof URL ? url : new URL(url), jwt, customFetch, protocolVersion);
export function openHttp(url: string | URL, jwt?: string, customFetch?: unknown | undefined, protocolVersion: ProtocolVersion = 2, config: ClientConfig = {}): HttpClient {
return new HttpClient(url instanceof URL ? url : new URL(url), jwt, customFetch, protocolVersion, config);
}
14 changes: 9 additions & 5 deletions src/result.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ClientConfig } from "./client.js";
import { ClientError, ProtoError, ResponseError } from "./errors.js";
import type * as proto from "./shared/proto.js";
import type { Value, IntMode } from "./value.js";
Expand Down Expand Up @@ -52,17 +53,17 @@ export function stmtResultFromProto(result: proto.StmtResult): StmtResult {
};
}

export function rowsResultFromProto(result: proto.StmtResult, intMode: IntMode): RowsResult {
export function rowsResultFromProto(result: proto.StmtResult, intMode: IntMode, config: ClientConfig): RowsResult {
const stmtResult = stmtResultFromProto(result);
const rows = result.rows.map(row => rowFromProto(stmtResult.columnNames, row, intMode));
const rows = result.rows.map(row => rowFromProto(stmtResult.columnNames, row, intMode, stmtResult.columnDecltypes, config));
return {...stmtResult, rows};
}

export function rowResultFromProto(result: proto.StmtResult, intMode: IntMode): RowResult {
export function rowResultFromProto(result: proto.StmtResult, intMode: IntMode, config: ClientConfig): RowResult {
const stmtResult = stmtResultFromProto(result);
let row: Row | undefined;
if (result.rows.length > 0) {
row = rowFromProto(stmtResult.columnNames, result.rows[0], intMode);
row = rowFromProto(stmtResult.columnNames, result.rows[0], intMode, stmtResult.columnDecltypes, config);
}
return {...stmtResult, row};
}
Expand All @@ -71,6 +72,7 @@ export function valueResultFromProto(result: proto.StmtResult, intMode: IntMode)
const stmtResult = stmtResultFromProto(result);
let value: Value | undefined;
if (result.rows.length > 0 && stmtResult.columnNames.length > 0) {
// TODO: How do we solve this? AFAICS we don't have column data when fetching a single value, so we don't know when to cast ints to booleans
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the biggest drawback of the whole PR I think. Since we don't get any column type data when fetching pure values, we can't know when we should cast. We'd have to make it very clear that the casting config only applies to query/queryRow and not when fetching values. Thoughts?

value = valueFromProto(result.rows[0][0], intMode);
}
return {...stmtResult, value};
Expand All @@ -80,12 +82,14 @@ function rowFromProto(
colNames: Array<string | undefined>,
values: Array<proto.Value>,
intMode: IntMode,
colDecltypes: Array<string | undefined>,
config: ClientConfig
): Row {
const row = {};
// make sure that the "length" property is not enumerable
Object.defineProperty(row, "length", { value: values.length });
for (let i = 0; i < values.length; ++i) {
const value = valueFromProto(values[i], intMode);
const value = valueFromProto(values[i], intMode, colDecltypes[i], config.castBooleans);
Object.defineProperty(row, i, { value });

const colName = colNames[i];
Expand Down
12 changes: 8 additions & 4 deletions src/stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Batch } from "./batch.js";
import type { Client } from "./client.js";
import type { Client, ClientConfig } from "./client.js";
import type { Cursor } from "./cursor.js";
import type { DescribeResult } from "./describe.js";
import { describeResultFromProto } from "./describe.js";
Expand All @@ -18,8 +18,9 @@ import type { IntMode } from "./value.js";
/** A stream for executing SQL statements (a "database connection"). */
export abstract class Stream {
/** @private */
constructor(intMode: IntMode) {
constructor(intMode: IntMode, config: ClientConfig) {
this.intMode = intMode;
this.config = config;
}

/** Get the client object that this stream belongs to. */
Expand Down Expand Up @@ -61,10 +62,10 @@ export abstract class Stream {
#execute<T>(
inStmt: InStmt,
wantRows: boolean,
fromProto: (result: proto.StmtResult, intMode: IntMode) => T,
fromProto: (result: proto.StmtResult, intMode: IntMode, config: ClientConfig) => T,
): Promise<T> {
const stmt = stmtToProto(this._sqlOwner(), inStmt, wantRows);
return this._execute(stmt).then((r) => fromProto(r, this.intMode));
return this._execute(stmt).then((r) => fromProto(r, this.intMode, this.config));
}

/** Return a builder for creating and executing a batch.
Expand Down Expand Up @@ -120,4 +121,7 @@ export abstract class Stream {
* This value affects the results of all operations on this stream.
*/
intMode: IntMode;

/** Stores the client configuration. */
config: ClientConfig;
}
8 changes: 6 additions & 2 deletions src/value.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClientError, ProtoError, MisuseError } from "./errors.js";
import { ProtoError, MisuseError } from "./errors.js";
import type * as proto from "./shared/proto.js";
import { impossible } from "./util.js";

Expand All @@ -7,6 +7,7 @@ export type Value =
| null
| string
| number
| boolean
| bigint
| ArrayBuffer

Expand Down Expand Up @@ -65,14 +66,17 @@ export function valueToProto(value: InValue): proto.Value {
const minInteger = -9223372036854775808n;
const maxInteger = 9223372036854775807n;

export function valueFromProto(value: proto.Value, intMode: IntMode): Value {
export function valueFromProto(value: proto.Value, intMode: IntMode, colDecltype?: string, castBooleans?: boolean): Value {
if (value === null) {
return null;
} else if (typeof value === "number") {
return value;
} else if (typeof value === "string") {
return value;
} else if (typeof value === "bigint") {
if (castBooleans && colDecltype?.toLowerCase() === 'boolean') {
return Boolean(value);
}
if (intMode === "number") {
const num = Number(value);
if (!Number.isSafeInteger(num)) {
Expand Down
Loading