diff --git a/README.md b/README.md index 6d31e29..cb475b2 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,14 @@ console.log(dump(buffer)); ## Packages +- [assert](https://jsr.io/@stdext/assert): The assert package, contains + validators and assertions +- [collections](https://jsr.io/@stdext/collections): The collections package + contains commonly used utilities and structures - [crypto](https://jsr.io/@stdext/crypto): The crypto package contains utility for crypto and hashing +- [database](https://jsr.io/@stdext/database): The database package contains + interfaces and helpers for interracting with databases - [encoding](https://jsr.io/@stdext/encoding): The encoding package contains utility for text encoding. - [http](https://jsr.io/@stdext/http): The http package contains utility for diff --git a/assert/is_number.ts b/assert/is_number.ts index 00d56b4..b9efc09 100644 --- a/assert/is_number.ts +++ b/assert/is_number.ts @@ -10,8 +10,13 @@ export function isNumber(value: unknown): value is number { /** * Asserts that a value is a number */ -export function assertIsNumber(value: unknown): asserts value is number { +export function assertIsNumber( + value: unknown, + msg?: string, +): asserts value is number { if (!isNumber(value)) { - throw new AssertionError(`Value is not a number, was '${value}'`); + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `Value is not a number, was '${value}'${msgSuffix}`; + throw new AssertionError(message); } } diff --git a/assert/is_numeric.ts b/assert/is_numeric.ts index 5693528..dc9a4a7 100644 --- a/assert/is_numeric.ts +++ b/assert/is_numeric.ts @@ -11,8 +11,13 @@ export function isNumeric(value: unknown): value is number { /** * Asserts that a value is a number and not NaN */ -export function assertIsNumeric(value: unknown): asserts value is number { +export function assertIsNumeric( + value: unknown, + msg?: string, +): asserts value is number { if (!isNumeric(value)) { - throw new AssertionError(`Value is not a numeric, was '${value}'`); + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `Value is not a numeric, was '${value}'${msgSuffix}`; + throw new AssertionError(message); } } diff --git a/assert/is_object.test.ts b/assert/is_object.test.ts new file mode 100644 index 0000000..6ee878f --- /dev/null +++ b/assert/is_object.test.ts @@ -0,0 +1,42 @@ +import { assert, assertFalse, AssertionError, assertThrows } from "@std/assert"; +import { assertIsObject, isObject } from "./is_object.ts"; + +const VALID = [ + {}, + { a: 1 }, + { 1: "a" }, + { [Symbol.dispose]: "" }, +]; + +const INVALID = [ + "", + 1, + undefined, + null, + [], + [""], + new Map(), +]; + +Deno.test("isObject > can detect records", () => { + for (const v of VALID) { + assert(isObject(v), `Value of '${v}' is not valid`); + } + for (const v of INVALID) { + assertFalse(isObject(v), `Value of '${v}' is not invalid`); + } +}); + +Deno.test("assertIsObject > can detect records", () => { + for (const v of VALID) { + assertIsObject(v); + } + for (const v of INVALID) { + assertThrows( + () => assertIsObject(v), + AssertionError, + undefined, + `Value of '${v}' did not throw`, + ); + } +}); diff --git a/assert/is_object.ts b/assert/is_object.ts new file mode 100644 index 0000000..cd81847 --- /dev/null +++ b/assert/is_object.ts @@ -0,0 +1,27 @@ +import { AssertionError } from "@std/assert"; +import { objectToStringEquals } from "./utils.ts"; + +/** + * Checks if a value is an object + */ +export function isObject(value: unknown): value is object { + if (!objectToStringEquals("Object", value)) { + return false; + } + + return true; +} + +/** + * Asserts that a value is an object + */ +export function assertIsObject( + value: unknown, + msg?: string, +): asserts value is object { + if (!isObject(value)) { + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `Value is not a object, was '${value}'${msgSuffix}`; + throw new AssertionError(message); + } +} diff --git a/assert/is_record.ts b/assert/is_record.ts index 3561f68..a044f9f 100644 --- a/assert/is_record.ts +++ b/assert/is_record.ts @@ -1,15 +1,11 @@ import { AssertionError } from "@std/assert"; -import { objectToStringEquals } from "./utils.ts"; +import { isObject } from "./is_object.ts"; /** * Checks if a value is a Record */ export function isRecord(value: unknown): value is Record { - if (!objectToStringEquals("Object", value)) { - return false; - } - - if (typeof value !== "object") { + if (!isObject(value)) { return false; } @@ -29,8 +25,11 @@ export function isRecord(value: unknown): value is Record { */ export function assertIsRecord( value: unknown, + msg?: string, ): asserts value is Record { if (!isRecord(value)) { - throw new AssertionError(`Value is not a Record, was '${value}'`); + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `Value is not a Record, was '${value}'${msgSuffix}`; + throw new AssertionError(message); } } diff --git a/assert/is_string.ts b/assert/is_string.ts index f7b9c29..780fe3f 100644 --- a/assert/is_string.ts +++ b/assert/is_string.ts @@ -10,8 +10,13 @@ export function isString(value: unknown): value is string { /** * Asserts that a value is a string */ -export function assertIsString(value: unknown): asserts value is string { +export function assertIsString( + value: unknown, + msg?: string, +): asserts value is string { if (!isString(value)) { - throw new AssertionError(`Value is not a string, was '${value}'`); + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `Value is not a string, was '${value}'${msgSuffix}`; + throw new AssertionError(message); } } diff --git a/assert/mod.ts b/assert/mod.ts index a766d00..9cda69a 100644 --- a/assert/mod.ts +++ b/assert/mod.ts @@ -1,4 +1,6 @@ export * from "./is_number.ts"; export * from "./is_numeric.ts"; +export * from "./is_object.ts"; export * from "./is_record.ts"; export * from "./is_string.ts"; +export * from "./object_has_properties.ts"; diff --git a/assert/object_has_properties.test.ts b/assert/object_has_properties.test.ts new file mode 100644 index 0000000..c5a0012 --- /dev/null +++ b/assert/object_has_properties.test.ts @@ -0,0 +1,93 @@ +import { assert, assertFalse, AssertionError, assertThrows } from "@std/assert"; +import { + assertObjectHasProperties, + assertObjectHasPropertiesDeep, + objectHasProperties, + objectHasPropertiesDeep, +} from "./object_has_properties.ts"; + +const VALID: Array<[object, Array]> = [ + [{ [Symbol.for("test")]: 0 }, [Symbol.for("test")]], + [{ 1: 0 }, [1]], + [{ 0: 0 }, [0]], + [{ "": 0 }, [""]], + [{ "test": 0 }, ["test"]], +]; + +const INVALID: Array<[object, Array]> = [ + [{}, [Symbol.for("test")]], + [{ [Symbol.for("test2")]: 0 }, [Symbol.for("test")]], + [{}, [1]], + [{}, [""]], + [{}, ["test"]], +]; + +class NestedClass extends (class { + get test() { + return "test"; + } +}) {} +const nestedClass = new NestedClass(); + +const INVALID_NOT_DEEP: Array<[object, Array]> = [ + ...INVALID, + [nestedClass, ["test"]], +]; +const VALID_DEEP: Array<[object, Array]> = [ + ...VALID, + [nestedClass, ["test"]], +]; + +Deno.test("objectHasProperties > can detect all property keys", () => { + for (const v of VALID) { + assert( + objectHasProperties(v[0], v[1]), + `Value of '${JSON.stringify(v)}' is not valid`, + ); + } + for (const v of INVALID_NOT_DEEP) { + assertFalse( + objectHasProperties(v[0], v[1]), + `Value of '${JSON.stringify(v)}' is not invalid`, + ); + } +}); + +Deno.test("assertObjectHasProperties > can detect all property keys", () => { + for (const v of VALID) { + assertObjectHasProperties(v[0], v[1]); + } + for (const v of INVALID) { + assertThrows( + () => assertObjectHasProperties(v[0], v[1]), + AssertionError, + ); + } +}); + +Deno.test("objectHasPropertiesDeep > can detect all property keys", () => { + for (const v of VALID_DEEP) { + assert( + objectHasPropertiesDeep(v[0], v[1]), + `Value of '${JSON.stringify(v)}' is not valid`, + ); + } + for (const v of INVALID) { + assertFalse( + objectHasPropertiesDeep(v[0], v[1]), + `Value of '${JSON.stringify(v)}' is not invalid`, + ); + } +}); + +Deno.test("assertObjectHasPropertiesDeep > can detect all property keys", () => { + for (const v of VALID) { + assertObjectHasPropertiesDeep(v[0], v[1]); + } + for (const v of INVALID) { + assertThrows( + () => assertObjectHasPropertiesDeep(v[0], v[1]), + AssertionError, + ); + } +}); diff --git a/assert/object_has_properties.ts b/assert/object_has_properties.ts new file mode 100644 index 0000000..430ef22 --- /dev/null +++ b/assert/object_has_properties.ts @@ -0,0 +1,130 @@ +import { AssertionError } from "@std/assert"; +import { assertIsObject, isObject } from "./is_object.ts"; + +/** + * Check if an object has a property + */ +function hasProperty( + obj: T, + property: PropertyKey, + deep: boolean, +): boolean { + let currentProto = obj; + + while (currentProto !== null && currentProto !== undefined) { + if (Object.hasOwn(currentProto, property)) { + return true; + } + const descriptor = Object.getOwnPropertyDescriptor( + currentProto, + property, + ); + if (descriptor !== undefined) { + return true; + } + if (!deep) { + return false; + } + currentProto = Object.getPrototypeOf(currentProto); + } + + return false; +} + +export function getKeyDiff( + value: object, + keys: Array, + deep: boolean, +): Array { + const diff: PropertyKey[] = []; + + for (const key of keys) { + if (!hasProperty(value, key, deep)) { + diff.push(key); + } + } + + return diff; +} + +/** + * Checks if an object has given property keys + */ +export function objectHasProperties( + value: unknown, + keys: Array, +): value is Record { + if (!isObject(value)) { + return false; + } + + const diff = getKeyDiff(value, keys, false); + + return diff.length < 1; +} + +/** + * Asserts that an object has given property keys + */ +export function assertObjectHasProperties( + value: unknown, + keys: Array, + msg?: string, +): asserts value is Record { + assertIsObject(value); + + const diff = getKeyDiff(value, keys, false); + + if (diff.length > 0) { + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `The object is missing the following keys: [${ + keys.map(String).join(",") + }]${msgSuffix}`; + throw new AssertionError(message); + } +} + +/** + * Checks deeply if an object has given property keys + * + * Use when wanting to check for getters and other prototype + * properties on multilevel inheritance + */ +export function objectHasPropertiesDeep( + value: unknown, + keys: Array, +): value is Record { + if (!isObject(value)) { + return false; + } + + const diff = getKeyDiff(value, keys, true); + + return diff.length < 1; +} + +/** + * Asserts that an object has given property keys + * + * Use when wanting to check for getters and other prototype + * properties on multilevel inheritance + */ +export function assertObjectHasPropertiesDeep< + T extends PropertyKey = PropertyKey, +>( + value: unknown, + keys: Array, + msg?: string, +): asserts value is Record { + assertIsObject(value); + + const diff = getKeyDiff(value, keys, true); + + if (diff.length > 0) { + const msgSuffix = msg ? `: ${msg}` : "."; + const message = `The object is missing the following keys: [${ + keys.map(String).join(",") + }]${msgSuffix}`; + throw new AssertionError(message); + } +} diff --git a/collections/README.md b/collections/README.md new file mode 100644 index 0000000..449fbec --- /dev/null +++ b/collections/README.md @@ -0,0 +1,17 @@ +# @stdext/collections + +The collections package contains commonly used utilities and structures. + +## Entrypoints + +### Deferred Stack + +Contains the DeferredStack utility class. + +```ts +const deferred = new DeferredStack({ maxSize: 1 }); +deferred.add(1); +const e1 = await deferred.pop(); +setTimeout(() => e1.release(), 5000); +const e2 = await deferred.pop(); // will be queued until e1 is released +``` diff --git a/collections/deferred_stack.test.ts b/collections/deferred_stack.test.ts new file mode 100644 index 0000000..24f867b --- /dev/null +++ b/collections/deferred_stack.test.ts @@ -0,0 +1,220 @@ +import { assert, assertEquals, assertFalse, assertThrows } from "@std/assert"; +import { DeferredStack } from "./deferred_stack.ts"; + +Deno.test("collections/deferred_stack/DeferredStack", async (t) => { + await t.step("fill and empty x2", async () => { + const deferred = new DeferredStack({ maxSize: 2 }); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 0); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 0); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 0); + + deferred.add(1); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 1); + assertEquals(deferred.stack.length, 1); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 1); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 1); + assertEquals(deferred.queuedCount, 0); + + deferred.add(2); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 2); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 2); + assertEquals(deferred.queuedCount, 0); + + assertThrows(() => deferred.add(3), Error, "Max size reached"); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 2); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 2); + assertEquals(deferred.queuedCount, 0); + + const e1 = await deferred.pop(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 1); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 1); + assertEquals(deferred.availableCount, 1); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, true); + assertEquals(e1.value, 2); + await e1.release(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 2); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 2); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertThrows(() => e1.value, Error, "Element is not active"); + + const e2 = await deferred.pop(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 1); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 1); + assertEquals(deferred.availableCount, 1); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, true); + assertEquals(e2.value, 2); + + const e3 = await deferred.pop(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 2); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e3.active, true); + assertEquals(e3.value, 1); + + let e4Resolved = false; + let e5Resolved = false; + + const e4 = deferred.pop().then((r) => { + e4Resolved = true; + return r; + }); + assertFalse(e4Resolved); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 1); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 2); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 1); + + const e5 = deferred.pop().then((r) => { + e5Resolved = true; + return r; + }); + assertFalse(e5Resolved); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 2); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 2); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 2); + + await e2.release(); + await e4; + assert(e4Resolved); + assertFalse(e5Resolved); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 1); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 2); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 1); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + + await e3.release(); + await e5; + assert(e5Resolved); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 2); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + assertEquals(e3.active, false); + + await (await e4).release(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 1); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 1); + assertEquals(deferred.availableCount, 1); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + assertEquals(e3.active, false); + assertEquals((await e4).active, false); + + await (await e5).release(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 2); + assertEquals(deferred.stack.length, 2); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 2); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 2); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + assertEquals(e3.active, false); + assertEquals((await e4).active, false); + assertEquals((await e5).active, false); + + const e6 = await deferred.pop(); + await e6.remove(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 1); + assertEquals(deferred.stack.length, 1); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 1); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 1); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + assertEquals(e3.active, false); + assertEquals((await e4).active, false); + assertEquals((await e5).active, false); + assertEquals(e6.active, false); + + const e7 = await deferred.pop(); + await e7.remove(); + assertEquals(deferred.maxSize, 2); + assertEquals(deferred.elements.length, 0); + assertEquals(deferred.stack.length, 0); + assertEquals(deferred.queue.length, 0); + assertEquals(deferred.totalCount, 0); + assertEquals(deferred.inUseCount, 0); + assertEquals(deferred.availableCount, 0); + assertEquals(deferred.queuedCount, 0); + assertEquals(e1.active, false); + assertEquals(e2.active, false); + assertEquals(e3.active, false); + assertEquals((await e4).active, false); + assertEquals((await e5).active, false); + assertEquals(e6.active, false); + assertEquals(e7.active, false); + }); +}); diff --git a/collections/deferred_stack.ts b/collections/deferred_stack.ts new file mode 100644 index 0000000..dbd7d3e --- /dev/null +++ b/collections/deferred_stack.ts @@ -0,0 +1,307 @@ +/** + * DeferredStackOptions + * + * Options for DeferredStack + */ +export type DeferredStackOptions = { + /** + * The maximum stack size to be allowed. + */ + maxSize?: number; + /** + * The release function to be called when the element is released + */ + releaseFn?: (element: DeferredStackElement) => Promise | void; + /** + * The remove function to be called when the element is removed + */ + removeFn?: (element: DeferredStackElement) => Promise | void; +}; + +/** + * DeferredStack + * + * When you have a stack that you want to defer the acquire of an element until it is available. + * + * ```ts + * const deferred = new DeferredStack({ maxSize: 1 }); + * deferred.add(1); + * const e1 = await deferred.pop(); + * setTimeout(() => e1.release(), 5000); + * const e2 = await deferred.pop(); // will be queued until e1 is released + * ``` + */ +export class DeferredStack { + /** + * The maximum stack size to be allowed, if the stack is full, the acquire will be queued. + * + * @default 10 + */ + readonly maxSize: number = 10; + #releaseFn?: DeferredStackOptions["releaseFn"]; + #removeFn?: DeferredStackOptions["removeFn"]; + + /** + * The list of all elements + * + * Cannot be larger than maxSize + */ + #elements: Array> = []; + + /** + * The stack of available elements + */ + #stack: Array> = []; + + /** + * The queue of requested connections + */ + readonly queue: Array>> = []; + + /** + * The list of all elements + */ + get elements(): Array> { + return this.#elements; + } + + /** + * The stack of available elements + */ + get stack(): Array> { + return this.#stack; + } + + /** + * The number of elements in the stack + */ + get totalCount(): number { + return this.#elements.length; + } + + /** + * The number of elements in the stack + */ + get inUseCount(): number { + return this.#elements.length - this.availableCount; + } + + /** + * The number of available elements in the stack + */ + get availableCount(): number { + return this.#stack.length; + } + + /** + * The number of queued acquires + */ + get queuedCount(): number { + return this.queue.length; + } + + constructor(options?: DeferredStackOptions) { + this.maxSize = options?.maxSize ?? 10; + this.#releaseFn = options?.releaseFn; + this.#removeFn = options?.removeFn; + } + + /** + * Add an element to the stack + * + * If there are any queued acquires, the first one will be resolved with the pushed element. + * If the stack is full, an error will be thrown. + * + * @throws Error("Max size reached") + */ + add(element: T): void { + if (this.#elements.length >= this.maxSize) { + throw new Error("Max size reached"); + } + + const newElement = new DeferredStackElement({ + value: element, + releaseFn: async (element) => { + await this.#release(element); + await this.#releaseFn?.(element); + }, + removeFn: async (element) => { + this.#remove(element); + await this.#removeFn?.(element); + }, + }); + + this.#elements.push(newElement); + + this.#push(newElement); + } + + /** + * Pop an element from the stack + * + * If there are no elements in the stack, the acquire will be queued and resolved when an element is pushed. + */ + pop(): Promise> { + const element = this.#stack.pop(); + + if (element) { + element._activate(); + return Promise.resolve(element); + } + + const p = Promise.withResolvers>(); + + this.queue.push(p); + + return p.promise; + } + + /** + * Push an element to the stack or resolve the first queued acquire + */ + #push(element: DeferredStackElement): void { + if (this.queue.length) { + const p = this.queue.shift()!; + element._activate(); + p.resolve(element); + } else { + this.#stack.push(element); + } + } + + /** + * Release element back to the deferred stack + * + * To avoid that previous users of the element can still access it, + * the element is removed from the stack, and added again. + */ + async #release(element: DeferredStackElement): Promise { + const value = element.value; + await element.remove(); + this.add(value); + } + + /** + * Removes element from the deferred stack + */ + #remove(element: DeferredStackElement): void { + this.#elements = this.#elements.filter((el) => el._id !== element._id); + this.#stack = this.#stack.filter((el) => el._id !== element._id); + } +} + +/** + * DeferredStackElementOptions + */ +export type DeferredStackElementOptions = { + /** + * The value of the element + */ + value: T; + /** + * The release function to be called when the element is released + */ + releaseFn: (element: DeferredStackElement) => Promise; + /** + * The remove function to be called when the element is removed + */ + removeFn: (element: DeferredStackElement) => Promise; +}; + +/** + * DeferredStackElement + * + * Represents an element in the DeferredStack with helpful methods to manage it. + * + * To access the value of the element, use the `value` property. + */ +export class DeferredStackElement { + /** + * The unique identifier of the element + */ + _id: string = crypto.randomUUID(); + + /** + * Whether the element is in use + */ + #active = false; + + /** + * Whether the element is disposed and should not be available anymore + */ + #disposed = false; + + /** + * The value of the element + */ + _value: T; + + /** + * The release function to be called when the element is released + */ + #releaseFn: DeferredStackElementOptions["releaseFn"]; + + /** + * The remove function to be called when the element is removed + */ + #removeFn: DeferredStackElementOptions["removeFn"]; + + /** + * Whether the element is in use + */ + get active(): boolean { + return this.#active; + } + + /** + * Whether the element is disposed and should not be available anymore + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * The value of the element + * + * @throws Error("Element is not active") + * @throws Error("Element is disposed") + */ + get value(): T { + if (!this.active) throw new Error("Element is not active"); + if (this.#disposed) throw new Error("Element is disposed"); + return this._value; + } + + constructor( + options: DeferredStackElementOptions, + ) { + this._value = options.value; + this.#releaseFn = options.releaseFn; + this.#removeFn = options.removeFn; + } + + /** + * Activates the element + * + * Only the DeferredStack should call this method. + */ + _activate(): void { + this.#active = true; + } + + /** + * Releases the element back to the DeferredStack + */ + release(): ReturnType["releaseFn"]> { + return this.#releaseFn(this); + } + + /** + * Removes the element from the DeferredStack + */ + remove(): ReturnType["removeFn"]> { + this.#active = false; + this.#disposed = true; + return this.#removeFn(this); + } +} diff --git a/collections/deno.json b/collections/deno.json new file mode 100644 index 0000000..d8e7b9b --- /dev/null +++ b/collections/deno.json @@ -0,0 +1,8 @@ +{ + "version": "0.0.5", + "name": "@stdext/collections", + "exports": { + ".": "./mod.ts", + "./deferred-stack": "./deferred_stack.ts" + } +} diff --git a/collections/mod.ts b/collections/mod.ts new file mode 100644 index 0000000..92eecbe --- /dev/null +++ b/collections/mod.ts @@ -0,0 +1 @@ +export * from "./deferred_stack.ts"; diff --git a/crypto/hash.test.ts b/crypto/hash.test.ts index c2fc339..276b475 100644 --- a/crypto/hash.test.ts +++ b/crypto/hash.test.ts @@ -2,11 +2,9 @@ import { assert, assertMatch, assertThrows } from "@std/assert"; import { hash, verify } from "./hash.ts"; Deno.test("hash() and verify() with unsupported", () => { - // deno-lint-ignore ban-ts-comment - // @ts-ignore + // @ts-expect-error: ts-inference assertThrows(() => hash("unsupported", "password")); - // deno-lint-ignore ban-ts-comment - // @ts-ignore + // @ts-expect-error: ts-inference assertThrows(() => verify("unsupported", "password", "")); }); diff --git a/crypto/hash/argon2.test.ts b/crypto/hash/argon2.test.ts index 82e85ad..fc2508b 100644 --- a/crypto/hash/argon2.test.ts +++ b/crypto/hash/argon2.test.ts @@ -22,8 +22,7 @@ Deno.test("hash() and verify() with argon2d", () => { }); Deno.test("hash() and verify() with wrong algorithm", () => { - // deno-lint-ignore ban-ts-comment - // @ts-ignore + // @ts-expect-error: ts-inference const o = { algorithm: "asdfasdf" } as Argon2Options; const h = hash("password", o); assertMatch(h, /^\$argon2id\$v=19\$m=19456,t=2,p=1\$/); diff --git a/crypto/totp.test.ts b/crypto/totp.test.ts index aafdab3..f5c43fd 100644 --- a/crypto/totp.test.ts +++ b/crypto/totp.test.ts @@ -5,7 +5,6 @@ const secret = "OCOMBLGUREYUXFQJIL75FQFCKYFCKLQP"; const t = 1704067200000; Deno.test("generateTotp()", async () => { - console.log(); assertEquals(await generateTotp(secret, 0, t), "342743"); assertEquals(await generateTotp(secret, 944996400, t), "149729"); assertEquals(await generateTotp(secret, 976618800, t), "372018"); diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..f3afb2d --- /dev/null +++ b/database/README.md @@ -0,0 +1,25 @@ +# @stdext/database + +The database package contains interfaces and helpers for interracting with +databases. It draws inspiration from +[go std/database](https://pkg.go.dev/database). + +## Entrypoints + +### Sql + +The SQL package contains a standard interface for SQL based databases + +> The SQL entrypoint is not intended to be directly used in applications, but is +> meant to be implemented by database drivers. This is mainly for library +> authors. + +Databases implementing these interfaces can be used as following (see +[database/sql](./sql/README.md) for more details): + +```ts +await using client = new Client(connectionUrl, connectionOptions); +await client.connect(); +await client.execute("SOME INSERT QUERY"); +const res = await client.query("SELECT * FROM table"); +``` diff --git a/database/deno.json b/database/deno.json new file mode 100644 index 0000000..7daea2a --- /dev/null +++ b/database/deno.json @@ -0,0 +1,8 @@ +{ + "version": "0.0.0-5", + "name": "@stdext/database", + "exports": { + "./sql": "./sql/mod.ts", + "./sql/testing": "./sql/testing.ts" + } +} diff --git a/database/sql/README.md b/database/sql/README.md new file mode 100644 index 0000000..e0a77ef --- /dev/null +++ b/database/sql/README.md @@ -0,0 +1,344 @@ +# @stdext/database/sql + +The SQL package contains a standard interface for SQL based databases + +Inspired by [rust sqlx](https://docs.rs/sqlx/latest/sqlx/index.html) and +[go sql](https://pkg.go.dev/database/sql). + +The goal for this package is to have a standard interface for SQL-like database +clients that can be used in Deno, Node and other JS runtimes. + +## Usage + +Minimal usage example: + +```ts +await using client = new Client(connectionUrl, connectionOptions); +await client.connect(); +await client.execute("SOME INSERT QUERY"); +const res = await client.query("SELECT * FROM table"); +``` + +Both the `Client` and `ClientPool` need to be connected using `connect()` before +the database can be queried. At the end of the script, this connection also +needs to be cleaned up by calling `close()`. If using the new +[AsyncDispose](https://github.com/tc39/proposal-explicit-resource-management), +there is no need to call `close()` manually as shown in the example above. + +See the [examples](#examples) section for more usage. + +### Client + +The Client provides a database client with the following methods (see +[Client](./client.ts)): + +- `connect` (See [Driver](./driver.ts)): Creates a connection to the database +- `close` (See [Driver](./driver.ts)): Closes the connection to the database +- `execute` (See [Queriable](./core.ts)): Executes a SQL statement +- `query` (See [Queriable](./core.ts)): Queries the database and returns an + array of object +- `queryOne` (See [Queriable](./core.ts)): Queries the database and returns at + most one entry as an object +- `queryMany` (See [Queriable](./core.ts)): Queries the database with an async + generator and yields each entry as an object. This is good for when you want + to iterate over a massive amount of rows. +- `queryArray` (See [Queriable](./core.ts)): Queries the database and returns an + array of arrays +- `queryOneArray` (See [Queriable](./core.ts)): Queries the database and returns + at most one entry as an array +- `queryManyArray` (See [Queriable](./core.ts)): Queries the database with an + async generator and yields each entry as an array. This is good for when you + want to iterate over a massive amount of rows. +- `sql` (See [Queriable](./core.ts)): Allows you to create a query using + template literals, and returns the entries as an array of objects. This is a + wrapper around `query` +- `sqlArray` (See [Queriable](./core.ts)): Allows you to create a query using + template literals, and returns the entries as an array of arrays. This is a + wrapper around `queryArray` +- `prepare` (See [Preparable](./core.ts)): Returns a prepared statement class + that contains a subset of the Queriable functions (see + [PreparedQueriable](./core.ts)) +- `beginTransaction` (See [Transactionable](./core.ts)): Returns a transaction + class that contains implements the queriable functions, as well as transaction + related functions (see [TransactionQueriable](./core.ts)) +- `transaction` (See [Transactionable](./core.ts)): A wrapper function for + transactions, handles the logic of beginning, committing and rollback a + transaction. + +#### Events + +The following events can be subscribed to according to the specs (see +[events.ts](./events.ts)): + +- `connect`: Gets dispatched when a connection is established +- `close`: Gets dispatched when a connection is about to be closed +- `error`: Gets dispatched when an error is triggered + +### ClientPool + +The ClientPool provides a database client pool (a pool of clients) with the +following methods (see [ClientPool](./pool.ts)): + +- `connect` (See [Driver](./driver.ts)): Creates the connection classes and adds + them to a connection pool, and optionally connects them to the database +- `close` (See [Driver](./driver.ts)): Closes all connections in the pool +- `acquire` (See [Poolable](./core.ts)): Retrieves a [PoolClient](./pool.ts) (a + subset of [Client](#client)), and connects if not already connected + +#### Events + +The following events can be subscribed to according to the specs (see +[events.ts](./events.ts)): + +- `connect`: Gets dispatched when a connection is established for a pool client + (with lazy initialization, it will only get triggered once a connection is + popped and activated) +- `close`: Gets dispatched when a connection is about to be closed +- `error`: Gets dispatched when an error is triggered +- `acquire`: Gets dispatched when a connection is acquired from the pool +- `release`: Gets dispatched when a connection is released back to the pool + +### Examples + +Async dispose + +```ts +await using client = new Client(connectionUrl, connectionOptions); +await client.connect(); +await client.execute("SOME INSERT QUERY"); +const res = await client.query("SELECT * FROM table"); +``` + +Using const (requires manual close at the end) + +```ts +const client = new Client(connectionUrl, connectionOptions); +await client.connect(); +await client.execute("SOME INSERT QUERY"); +const res = await client.query("SELECT * FROM table"); +await client.close(); +``` + +Query objects + +```ts +const res = await client.query("SELECT * FROM table"); +console.log(res); +// [{ col1: "some value" }] +``` + +Query one object + +```ts +const res = await client.queryOne("SELECT * FROM table"); +console.log(res); +// { col1: "some value" } +``` + +Query many objects with an iterator + +```ts +const res = Array.fromAsync(client.queryMany("SELECT * FROM table")); +console.log(res); +// [{ col1: "some value" }] + +// OR + +for await (const iterator of client.queryMany("SELECT * FROM table")) { + console.log(res); + // { col1: "some value" } +} +``` + +Query as an array + +```ts +const res = await client.queryArray("SELECT * FROM table"); +console.log(res); +// [[ "some value" ]] +``` + +Query one as an array + +```ts +const res = await client.queryOneArray("SELECT * FROM table"); +console.log(res); +// [[ "some value" ]] +``` + +Query many as array with an iterator + +```ts +const res = Array.fromAsync(client.queryManyArray("SELECT * FROM table")); +console.log(res); +// [[ "some value" ]] + +// OR + +for await (const iterator of client.queryManyArray("SELECT * FROM table")) { + console.log(res); + // [ "some value" ] +} +``` + +Query with template literals as an object + +```ts +const res = await client.sql`SELECT * FROM table where id = ${id}`; +console.log(res); +// [{ col1: "some value" }] +``` + +Query with template literals as an array + +```ts +const res = await client.sqlArray`SELECT * FROM table where id = ${id}`; +console.log(res); +// [[ "some value" ]] +``` + +Transaction + +```ts +const transaction = await client.beginTransaction(); +await transaction.execute("SOME INSERT QUERY"); +await transaction.commitTransaction(); +// `transaction` can no longer be used, and a new transaction needs to be created + +// OR + +await using transaction = await client.beginTransaction(); +await transaction.execute("SOME INSERT QUERY"); +// If commit is not called and the resource is cleaned up, rollback will be called automatically +``` + +Transaction wrapper + +```ts +const res = await client.transaction(async (t) => { + await t.execute("SOME INSERT QUERY"); + return t.query("SOME SELECT QUERY"); +}); +console.log(res); +// [{ col1: "some value" }] +``` + +Prepared statement + +```ts +const prepared = await db.prepare("SOME PREPARED STATEMENT"); +await prepared.query([...params]); +console.log(res); +// [{ col1: "some value" }] +await prepared.dealocate(); +// `prepared` can no longer be used, and a new prepared statement needs to be created + +// OR + +await using prepared = await db.prepare("SOME PREPARED STATEMENT"); +await prepared.query([...params]); +console.log(res); +// [{ col1: "some value" }] +// If dealocate is not called and the resource is cleaned up, dealocate will be called automatically +``` + +## Implementation + +> This section is for implementing the interface for database drivers. For +> general usage, read the [usage](#usage) section. + +To be fully compliant with the specs, you will need to implement the following +classes for your database driver: + +- `Driver` ([Driver](./driver.ts)): This represents the connection to the + database. This should preferably only contain the functionality of containing + a connection, and provide a minimum set of methods to be used to call the + database +- `PreparedStatement` ([PreparedStatement](./core.ts)): This represents a + prepared statement. All queriable methods must be implemented +- `Transaction` ([Transaction](./core.ts)): This represents a transaction. All + queriable methods must be implemented +- `Client` ([Client](./client.ts)): This represents a database client +- `ClientPool` ([ClientPool](./pool.ts)): This represents a pool of clients +- `PoolClient` ([PoolClient](./pool.ts)): This represents a client to be + provided by a pool + +It is also however advisable to create additional helper classes for easier +inheritance. + +There are also some utility functions available in [utils.ts](./utils.ts) to +work with the iterables. + +### Testing + +See the bottom of [test.ts](./test.ts) for a minimum but functional example of +how to implement these interfaces into intermediate classes. + +### Inheritance graph + +Here is an overview of the inheritance and flow of the different interfaces. In +most cases, these are the classes and the inheritance graph that should be +implemented. + +![inheritance flow](./_assets/inheritance_flowchart.jpg) + +### Constructor Signature + +The constructor also must follow a strict signature. + +The constructor for both the Client and the ClientPool follows the same +signature: + +1. `connectionUrl`: string | URL +2. `options`?: ConnectionOptions & QueryOptions + +As `ConnectionOptions` and `QueryOptions` can be extended, the options can be +used to customize the settings, thus having a standard 2 argument signature of +the constructor. + +> The current way to specify a constructor using interfaces in TS, is to use a +> combination of `implements` and `satisfies`. This will be updated if anything +> changes. + +#### Client + +The Client must have a constructor signature as follows. + +```ts +class DbClient extends Transactionable implements Client<...> { // Transactionable is a class implementing `Transactionable` + ... + // The constructor now has to satisfy the following signature + constructor( + connectionUrl: string | URL, + options: Client["options"], + ) { + ... + } + ... +} satisfies ClientConstructor<...>; + +// We need to also export the instance type of the client +export type Client = InstanceType; +``` + +#### ClientPool + +The ClientPool must have a constructor following the signature specified by +`ClientPoolConstructor`. + +```ts +const ClientPool = class extends Transactionable implements ClientPool<...> { // Transactionable is a class implementing `Transactionable` + ... + // The constructor now has to satisfy `ClientPoolConstructor` + constructor( + connectionUrl: string | URL, + options: ConnectionOptions & QueryOptions = {}, + ) { + ... + } + ... +} satisfies ClientPoolConstructor<...>; + +// We need to also export the instance type of the client pool +export type ClientPool = InstanceType; +``` diff --git a/database/sql/_assets/inheritance_flowchart.jpg b/database/sql/_assets/inheritance_flowchart.jpg new file mode 100644 index 0000000..84f9ea7 Binary files /dev/null and b/database/sql/_assets/inheritance_flowchart.jpg differ diff --git a/database/sql/asserts.ts b/database/sql/asserts.ts new file mode 100644 index 0000000..eb74a37 --- /dev/null +++ b/database/sql/asserts.ts @@ -0,0 +1,473 @@ +import { assertInstanceOf, AssertionError } from "@std/assert"; +import type { + Driver, + DriverConnectable, + DriverInternalOptions, +} from "./driver.ts"; +import type { Client } from "./client.ts"; +import type { + PreparedStatement, + Queriable, + Transaction, + Transactionable, +} from "./core.ts"; +import type { Eventable } from "./events.ts"; +import type { ClientPool, PoolClient } from "./pool.ts"; +import { SqlError } from "./errors.ts"; +import { + assertObjectHasPropertiesDeep, + isString, + objectHasProperties, + objectHasPropertiesDeep, +} from "@stdext/assert"; + +/** + * Check if a value is a connectionUrl + */ +export function isConnectionUrl(value: unknown): value is string | URL { + return isString(value) || value instanceof URL; +} + +/** + * Assert that a value is a connectionUrl + */ +export function assertIsConnectionUrl( + value: unknown, +): asserts value is string | URL { + if (!isConnectionUrl(value)) { + throw new AssertionError( + `The given value is not a valid connection url, must be 'string' or 'URL', but was '${value}'`, + ); + } +} + +/** + * Check if a value is driver options + */ +export function isDriverOptions< + T extends DriverInternalOptions = DriverInternalOptions, +>(value: unknown, otherKeys: string[] = []): value is T { + const driverOptionsKeys = ["connectionOptions", "queryOptions", ...otherKeys]; + + return objectHasProperties(value, driverOptionsKeys); +} + +/** + * Assert that a value is driver options + */ +export function assertIsDriverOptions< + T extends DriverInternalOptions = DriverInternalOptions, +>( + value: unknown, + otherKeys: string[] = [], +): asserts value is T { + const driverOptionsKeys = ["connectionOptions", "queryOptions", ...otherKeys]; + + if (!isDriverOptions(value, otherKeys)) { + throw new AssertionError( + `The given value is not valid driver options, must contain the following keys ${ + driverOptionsKeys.map(String).join(",") + }`, + ); + } +} + +/** + * Check if an error is a SqlError + */ +export function isSqlError(err: unknown): err is SqlError { + return err instanceof SqlError; +} + +/** + * Asserts that an error is a SqlError + */ +export function assertIsSqlError(err: unknown): asserts err is SqlError { + assertInstanceOf(err, SqlError); +} + +/** + * Check if an object is an AsyncDisposable + */ +export function isAsyncDisposable( + value: unknown, +): value is AsyncDisposable { + return objectHasPropertiesDeep(value, [Symbol.asyncDispose]); +} + +/** + * Asserts that an object is an AsyncDisposable + */ +export function assertIsAsyncDisposable( + value: unknown, +): asserts value is AsyncDisposable { + assertObjectHasPropertiesDeep( + value, + [ + Symbol.asyncDispose, + ], + ); +} + +/** + * Check if an object is a Driver + */ +export function isDriver( + value: unknown, +): value is Driver { + return isAsyncDisposable(value) && objectHasPropertiesDeep( + value, + [ + "options", + "connected", + "connect", + "close", + "execute", + "queryMany", + "queryManyArray", + ], + ); +} + +/** + * Asserts that an object is a Driver + */ +export function assertIsDriver( + value: unknown, +): asserts value is Driver { + assertIsAsyncDisposable(value); + assertObjectHasPropertiesDeep( + value, + [ + "options", + "connected", + "connect", + "close", + "ping", + "query", + ], + ); +} + +/** + * Check if an object is an DriverConnectable + */ +export function isDriverConnectable( + value: unknown, +): value is DriverConnectable { + return isAsyncDisposable(value) && objectHasPropertiesDeep( + value, + [ + "connection", + "connected", + ], + ) && isDriver(value.connection); +} + +/** + * Asserts that an object is an DriverConnectable + */ +export function assertIsDriverConnectable( + value: unknown, +): asserts value is DriverConnectable { + assertIsAsyncDisposable(value); + assertObjectHasPropertiesDeep( + value, + [ + "connection", + "connected", + ], + ); + assertIsDriver(value.connection); +} + +/** + * Check if an object is an PreparedStatement + */ +export function isPreparedStatement( + value: unknown, +): value is PreparedStatement { + return isDriverConnectable(value) && objectHasPropertiesDeep( + value, + [ + "sql", + "options", + "execute", + "query", + "queryOne", + "queryMany", + "queryArray", + "queryOneArray", + "queryManyArray", + ], + ); +} + +/** + * Asserts that an object is an PreparedStatement + */ +export function assertIsPreparedStatement( + value: unknown, +): asserts value is PreparedStatement { + assertIsDriverConnectable(value); + assertObjectHasPropertiesDeep( + value, + [ + "sql", + "options", + "execute", + "query", + "queryOne", + "queryMany", + "queryArray", + "queryOneArray", + "queryManyArray", + ], + ); +} + +/** + * Check if an object is an Queriable + */ +export function isQueriable( + value: unknown, +): value is Queriable { + return isDriverConnectable(value) && objectHasPropertiesDeep( + value, + [ + "options", + "execute", + "query", + "queryOne", + "queryMany", + "queryArray", + "queryOneArray", + "queryManyArray", + ], + ); +} + +/** + * Asserts that an object is an Queriable + */ +export function assertIsQueriable( + value: unknown, +): asserts value is Queriable { + assertIsDriverConnectable(value); + assertObjectHasPropertiesDeep( + value, + [ + "options", + "execute", + "query", + "queryOne", + "queryMany", + "queryArray", + "queryOneArray", + "queryManyArray", + ], + ); +} + +/** + * Check if an object is an Transaction + */ +export function isPreparable( + value: unknown, +): value is Queriable { + return isQueriable(value) && objectHasPropertiesDeep( + value, + [ + "prepare", + ], + ); +} + +/** + * Asserts that an object is an Transaction + */ +export function assertIsPreparable( + value: unknown, +): asserts value is Queriable { + assertIsQueriable(value); + assertObjectHasPropertiesDeep( + value, + [ + "prepare", + ], + ); +} + +/** + * Check if an object is an Transaction + */ +export function isTransaction( + value: unknown, +): value is Transaction { + return isPreparable(value) && objectHasPropertiesDeep( + value, + [ + "inTransaction", + "commitTransaction", + "rollbackTransaction", + "createSavepoint", + "releaseSavepoint", + ], + ); +} + +/** + * Asserts that an object is an Transaction + */ +export function assertIsTransaction( + value: unknown, +): asserts value is Transaction { + assertIsPreparable(value); + assertObjectHasPropertiesDeep( + value, + [ + "inTransaction", + "commitTransaction", + "rollbackTransaction", + "createSavepoint", + "releaseSavepoint", + ], + ); +} + +/** + * Check if an object is an Transactionable + */ +export function isTransactionable( + value: unknown, +): value is Transactionable { + return isPreparable(value) && objectHasPropertiesDeep( + value, + [ + "beginTransaction", + "transaction", + ], + ); +} + +/** + * Asserts that an object is an Transactionable + */ +export function assertIsTransactionable( + value: unknown, +): asserts value is Transactionable { + assertIsPreparable(value); + assertObjectHasPropertiesDeep( + value, + [ + "beginTransaction", + "transaction", + ], + ); +} + +/** + * Check if an object is an Eventable + */ +export function isEventable( + value: unknown, +): value is Eventable { + return objectHasPropertiesDeep(value, ["eventTarget"]) && + value.eventTarget instanceof EventTarget; +} + +/** + * Asserts that an object is an Eventable + */ +export function assertIsEventable( + value: unknown, +): asserts value is Eventable { + assertObjectHasPropertiesDeep(value, ["eventTarget"]); + assertInstanceOf(value.eventTarget, EventTarget); +} + +/** + * Check if an object is an Client + */ +export function isClient(value: unknown): value is Client { + return isDriver(value) && isQueriable(value) && + isTransactionable(value) && isEventable(value) && + objectHasPropertiesDeep(value, ["options"]); +} + +/** + * Asserts that an object is an Client + */ +export function assertIsClient(value: unknown): asserts value is Client { + isDriverConnectable(value); + assertIsQueriable(value); + assertIsTransactionable(value); + assertIsEventable(value); + assertObjectHasPropertiesDeep(value, ["options"]); +} + +/** + * Check if an object is an PoolClient + */ +export function isPoolClient( + value: unknown, +): value is PoolClient { + return isDriverConnectable(value) && isTransactionable(value) && + objectHasPropertiesDeep(value, [ + "options", + "disposed", + "release", + ]); +} + +/** + * Asserts that an object is an PoolClient + */ +export function assertIsPoolClient( + value: unknown, +): asserts value is PoolClient { + assertIsDriverConnectable(value); + assertIsTransactionable(value); + assertObjectHasPropertiesDeep(value, [ + "options", + "disposed", + "release", + ]); +} + +/** + * Check if an object is an ClientPool + */ +export function isClientPool( + value: unknown, +): value is ClientPool { + return isEventable(value) && isAsyncDisposable(value) && + objectHasPropertiesDeep(value, [ + "connectionUrl", + "options", + "connected", + "connect", + "close", + "deferredStack", + "acquire", + ]); +} + +/** + * Asserts that an object is an ClientPool + */ +export function assertIsClientPool( + value: unknown, +): asserts value is ClientPool { + assertIsEventable(value); + assertIsAsyncDisposable(value); + assertObjectHasPropertiesDeep(value, [ + "connectionUrl", + "options", + "connected", + "connect", + "close", + "deferredStack", + "acquire", + ]); +} diff --git a/database/sql/client.ts b/database/sql/client.ts new file mode 100644 index 0000000..edfe5f1 --- /dev/null +++ b/database/sql/client.ts @@ -0,0 +1,102 @@ +import type { + PreparedStatement, + Transaction, + Transactionable, + TransactionOptions, +} from "./core.ts"; +import type { + Driver, + DriverConnectionOptions, + DriverParameterType, + DriverQueryMeta, + DriverQueryOptions, + DriverQueryValues, +} from "./driver.ts"; +import type { Eventable, SqlEventTarget } from "./events.ts"; + +/** + * Client + * + * This represents a database client. When you need a single connection + * to the database, you will in most cases use this interface. + */ +export interface Client< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, + ITransaction extends Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + > = Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + >, + IEventTarget extends SqlEventTarget = SqlEventTarget, +> extends + Pick< + Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + "close" | "connect" | "connected" + >, + Transactionable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction + >, + Eventable, + AsyncDisposable { +} diff --git a/database/sql/core.ts b/database/sql/core.ts new file mode 100644 index 0000000..8442c19 --- /dev/null +++ b/database/sql/core.ts @@ -0,0 +1,591 @@ +// deno-lint-ignore-file no-explicit-any +import type { + Driver, + DriverConnectable, + DriverConnectionOptions, + DriverInternalOptions, + DriverParameterType, + DriverQueryMeta, + DriverQueryOptions, + DriverQueryValues, +} from "./driver.ts"; + +/** + * Row + * + * Row type for SQL queries, represented as an object entry. + */ +export type Row = Record; + +/** + * ArrayRow + * + * Row type for SQL queries, represented as an array entry. + */ +export type ArrayRow = T[]; + +/** + * TransactionOptions + * + * Core transaction options + * Used to type the options for the transaction methods + */ +export type TransactionOptions = { + beginTransactionOptions?: Record; + commitTransactionOptions?: Record; + rollbackTransactionOptions?: Record; +}; + +/** + * Internal Transaction options + */ +export interface TransactionInternalOptions< + IConnectionOptions extends DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions, + ITransactionOptions extends TransactionOptions, +> extends DriverInternalOptions { + transactionOptions: ITransactionOptions; +} + +/** + * PreparedQueriable + * + * Represents a prepared statement to be executed separately from creation. + */ +export interface PreparedStatement< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, +> extends + DriverConnectable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > { + readonly options: DriverInternalOptions; + + /** + * The SQL statement + */ + readonly sql: string; + + /** + * Whether the prepared statement has been deallocated or not. + */ + readonly deallocated: boolean; + + /** + * Deallocate the prepared statement + */ + deallocate(): Promise; + + /** + * Executes the prepared statement + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the number of affected rows if any + */ + execute( + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database with the prepared statement + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as object entries + */ + query = Row>( + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database with the prepared statement, and return at most one row + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the row returned by the query as an object entry, or undefined if no row is returned + */ + queryOne = Row>( + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database with the prepared statement, and return an iterator. + * Usefull when querying large datasets, as this should take advantage of data streams. + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as object entries + */ + queryMany = Row>( + params?: IParameterType[], + options?: IQueryOptions, + ): AsyncGenerator; + /** + * Query the database with the prepared statement + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as array entries + */ + queryArray = ArrayRow>( + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database with the prepared statement, and return at most one row + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the row returned by the query as an array entry, or undefined if no row is returned + */ + queryOneArray = ArrayRow>( + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + + /** + * Query the database with the prepared statement, and return an iterator. + * Usefull when querying large datasets, as this should take advantage of data streams. + * + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as array entries + */ + queryManyArray = ArrayRow>( + params?: IParameterType[], + options?: IQueryOptions, + ): AsyncGenerator; +} + +/** + * Queriable + * + * Represents an object that can execute SQL queries. + */ +export interface Queriable< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, +> extends + DriverConnectable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > { + readonly options: DriverInternalOptions; + + /** + * Execute a SQL statement + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the number of affected rows if any + */ + execute( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as object entries + */ + query = Row>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database and return at most one row + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the row returned by the query as an object entry, or undefined if no row is returned + */ + queryOne = Row>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database and return an iterator. + * Usefull when querying large datasets, as this should take advantage of data streams. + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as object entries + */ + queryMany = Row>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): AsyncGenerator; + /** + * Query the database + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as array entries + */ + queryArray = ArrayRow>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + /** + * Query the database and return at most one row + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the row returned by the query as an array entry, or undefined if no row is returned + */ + queryOneArray = ArrayRow>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): Promise; + + /** + * Query the database and return an iterator. + * Usefull when querying large datasets, as this should take advantage of data streams. + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query as array entries + */ + queryManyArray = ArrayRow>( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): AsyncGenerator; + + /** + * Query the database using tagged template + * + * @returns the rows returned by the query as object entries + */ + sql = Row>( + strings: TemplateStringsArray, + ...parameters: IParameterType[] + ): Promise; + + /** + * Query the database using tagged template + * + * @returns the rows returned by the query as array entries + */ + sqlArray = ArrayRow>( + strings: TemplateStringsArray, + ...parameters: IParameterType[] + ): Promise; +} + +/** + * Preparable + * + * Represents an object that can create a prepared statement. + */ +export interface Preparable< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, +> extends + Queriable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > { + /** + * Create a prepared statement that can be executed multiple times. + * This is useful when you want to execute the same SQL statement multiple times with different parameters. + * + * @param sql the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns a prepared statement + * + * @example + * ```ts + * const stmt = db.prepare("SELECT * FROM table WHERE id = ?"); + * + * for (let i = 0; i < 10; i++) { + * const row of stmt.query([i]) + * console.log(row); + * } + * ``` + */ + prepare( + sql: string, + options?: IQueryOptions, + ): Promise; +} + +/** + * Transaction + * + * Represents a transaction. + */ +export interface Transaction< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, +> extends + Preparable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement + > { + readonly options: TransactionInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions + >; + /** + * Whether the connection is in an active transaction or not. + */ + inTransaction: boolean; + + /** + * Commit the transaction + */ + commitTransaction( + options?: ITransactionOptions["commitTransactionOptions"], + ): Promise; + /** + * Rollback the transaction + */ + rollbackTransaction( + options?: ITransactionOptions["rollbackTransactionOptions"], + ): Promise; + /** + * Create a save point + * + * @param name the name of the save point + */ + createSavepoint(name?: string): Promise; + /** + * Release a save point + * + * @param name the name of the save point + */ + releaseSavepoint(name?: string): Promise; +} + +/** + * Transactionable + * + * Represents an object that can create a transaction and a prepared statement. + * + * This interface is to be implemented by any class that supports creating a prepared statement. + * A prepared statement should in most cases be unique to a connection, + * and should not live after the related connection is closed. + */ +export interface Transactionable< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, + ITransaction extends Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + > = Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + >, +> extends + Preparable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement + > { + readonly options: TransactionInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions + >; + /** + * Starts a transaction + */ + beginTransaction( + options?: ITransactionOptions["beginTransactionOptions"], + ): Promise; + + /** + * Transaction wrapper + * + * Automatically begins a transaction, executes the callback function, and commits the transaction. + * + * If the callback function throws an error, the transaction will be rolled back and the error will be rethrown. + * If the callback function returns successfully, the transaction will be committed. + * + * @param fn callback function to be executed within a transaction + * @returns the result of the callback function + */ + transaction( + fn: (t: ITransaction) => Promise, + ): Promise; +} diff --git a/database/sql/driver.test.ts b/database/sql/driver.test.ts new file mode 100644 index 0000000..95e0939 --- /dev/null +++ b/database/sql/driver.test.ts @@ -0,0 +1,186 @@ +import { + testDriverConnectable, + testDriverConstructorIntegration, +} from "./testing.ts"; +import type { + Driver, + DriverConnectable, + DriverConnectionOptions, + DriverInternalOptions, + DriverParameterType, + DriverQueryMeta, + DriverQueryNext, + DriverQueryOptions, + DriverQueryValues, +} from "./driver.ts"; +import { SqlError } from "./errors.ts"; +import { assert, assertEquals } from "@std/assert"; + +const testDbQueryParser = (sql: string) => { + return JSON.parse(sql); +}; + +type TestDriverParameterType = DriverParameterType; +type TestDriverQueryValues = DriverQueryValues>; +interface TestDriverQueryMeta extends DriverQueryMeta { + test?: string; +} +interface TestDriverConnectionOptions extends DriverConnectionOptions { + test?: string; +} +interface TestDriverQueryOptions extends DriverQueryOptions { + test?: string; +} + +class TestDriver implements + Driver< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestDriverParameterType, + TestDriverQueryValues, + TestDriverQueryMeta + > { + connectionUrl: string | URL; + options: DriverInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions + >; + _connected: boolean = false; + constructor( + connectionUrl: string | URL, + options: TestDriver["options"], + ) { + this.connectionUrl = connectionUrl; + this.options = options; + } + ping(): Promise { + if (!this._connected) { + throw new SqlError("not connected"); + } + return Promise.resolve(); + } + get connected(): boolean { + return this._connected; + } + connect(): Promise { + this._connected = true; + return Promise.resolve(); + } + close(): Promise { + this._connected = false; + return Promise.resolve(); + } + + async *query< + Values extends TestDriverQueryValues = TestDriverQueryValues, + Meta extends TestDriverQueryMeta = TestDriverQueryMeta, + >( + sql: string, + _params?: TestDriverParameterType[], + _options?: TestDriverQueryOptions, + ): AsyncGenerator> { + if (!this._connected) throw new SqlError("not connected"); + + const queryRes = testDbQueryParser(sql); + for (const row of queryRes) { + const res: DriverQueryNext = { + columns: Object.keys(row), + values: Object.values(row) as Values, + meta: {} as Meta, + }; + + yield res; + } + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +class TestDriverConnectable implements + DriverConnectable< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestDriverParameterType, + TestDriverQueryValues, + TestDriverQueryMeta, + TestDriver + > { + options: TestDriver["options"]; + connection: TestDriver; + get connected(): boolean { + return this.connection.connected; + } + + constructor( + connection: TestDriverConnectable["connection"], + options: TestDriverConnectable["options"], + ) { + this.connection = connection; + this.options = options; + } + [Symbol.asyncDispose](): Promise { + return this.connection.close(); + } +} + +const connectionUrl = "test"; +const options: TestDriver["options"] = { + connectionOptions: {}, + queryOptions: {}, +}; +const sql = "test"; + +const expects = { + connectionUrl, + options, + clientPoolOptions: options, + sql, +}; + +// Type tester +// @ts-expect-error: qwer is not allowed +const _testingDriverQueryValues: DriverQueryValues<["asdf"]> = ["asdf", "qwer"]; + +Deno.test(`Driver`, async (t) => { + await t.step("test suite", async (t) => { + await testDriverConstructorIntegration(t, TestDriver, [ + connectionUrl, + options, + ]); + }); + + await t.step("can query using loop", async () => { + await using conn = new TestDriver(connectionUrl, options); + await conn.connect(); + assert(conn.connected); + const rows: DriverQueryNext[] = []; + for await (const row of conn.query(JSON.stringify([{ a: "b" }]))) { + rows.push(row); + } + assertEquals(rows, [{ columns: ["a"], values: ["b"], meta: {} }]); + }); + + await t.step("can query using collect", async () => { + await using conn = new TestDriver(connectionUrl, options); + await conn.connect(); + assert(conn.connected); + const rows: DriverQueryNext[] = await Array.fromAsync( + conn.query(JSON.stringify([{ a: "b" }])), + ); + assertEquals(rows, [{ columns: ["a"], values: ["b"], meta: {} }]); + }); +}); + +Deno.test(`DriverConnectable`, async (t) => { + const connection = new TestDriver(connectionUrl, options); + const connectable = new TestDriverConnectable( + connection, + connection.options, + ); + + await t.step("test suite", () => { + testDriverConnectable(connectable, expects); + }); +}); diff --git a/database/sql/driver.ts b/database/sql/driver.ts new file mode 100644 index 0000000..89ba938 --- /dev/null +++ b/database/sql/driver.ts @@ -0,0 +1,193 @@ +/** + * Represents the supported value types the driver can handle + */ +// deno-lint-ignore no-explicit-any +export type DriverParameterType = T; + +/** + * Represents the values returned from the query + */ +// deno-lint-ignore no-explicit-any +export type DriverQueryValues = Array> = T; + +/** + * Represents the meta data returned from the query + */ +// deno-lint-ignore no-explicit-any +export type DriverQueryMeta = Record; + +/** + * DriverConnectionOptions + * + * The options that will be used when connecting to the database. + * + * This is a placeholder for future options. + */ +// deno-lint-ignore no-empty-interface +export interface DriverConnectionOptions {} + +/** + * DriverQueryOptions + * + * Options to pass to the query methods. + */ +export interface DriverQueryOptions { + /** + * A signal to abort the query. + */ + signal?: AbortSignal; + /** + * Transforms the value that will be sent to the database + */ + transformInput?: (value: unknown) => unknown; + /** + * Transforms the value received from the database + */ + transformOutput?: (value: unknown) => unknown; +} + +/** + * DriverQueryNext + * + * The representation of a query row. + */ +export type DriverQueryNext< + Values extends DriverQueryValues = DriverQueryValues, + Meta extends DriverQueryMeta = DriverQueryMeta, +> = { + /** + * The column headers in the same order as the values + */ + columns: string[]; + /** + * The values + */ + values: Values; + /** + * Aditional information from the query + */ + meta: Meta; +}; + +/** + * Internal Driver Options + */ +export interface DriverInternalOptions< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, +> { + connectionOptions: IConnectionOptions; + queryOptions: IQueryOptions; + // deno-lint-ignore no-explicit-any + [key: string | symbol | number]: any; +} + +/** + * DriverConnection + * + * This represents a connection to a database. + * When a user wants a single connection to the database, + * they should use a class implementing or using this interface. + * + * The class implementing this interface should be able to connect to the database, + * and have the following constructor arguments (if more options are needed, extend the DriverConnectionOptions): + * - connectionUrl: string|URL + * - connectionOptions?: DriverConnectionOptions; + */ +export interface Driver< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, +> extends AsyncDisposable { + /** + * Connection URL + */ + readonly connectionUrl: string | URL; + + /** + * Connection options + */ + readonly options: DriverInternalOptions; + + /** + * Whether the connection is connected to the database + */ + get connected(): boolean; + + /** + * Create a connection to the database + */ + connect(): Promise; + + /** + * Close the connection to the database + */ + close(): Promise; + + /** + * Pings the database connection to check that it's alive + * + * Throws an error if connection is not alive + */ + ping(): Promise; + + /** + * Query the database and return an iterator. + * + * @param sql the SQL statement + * @param params the parameters to bind to the SQL statement + * @param options the options to pass to the query method, will be merged with the global options + * @returns the rows returned by the query + */ + query< + Values extends IQueryValues = IQueryValues, + Meta extends IQueryMeta = IQueryMeta, + >( + sql: string, + params?: IParameterType[], + options?: IQueryOptions, + ): AsyncGenerator>; +} + +/** + * The driver constructor + */ +export interface DriverConstructor< + IDriver extends Driver = Driver, + IOptions extends IDriver["options"] = IDriver["options"], +> { + new (connectionUrl: string, options: IOptions): IDriver; +} + +/** + * DriverConnectable + * + * The base interface for everything that interracts with the connection like querying. + */ +export interface DriverConnectable< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, +> extends AsyncDisposable, Pick { + /** + * The connection to the database + */ + readonly connection: IDriver; +} diff --git a/database/sql/errors.ts b/database/sql/errors.ts new file mode 100644 index 0000000..57b1941 --- /dev/null +++ b/database/sql/errors.ts @@ -0,0 +1,11 @@ +/** + * SqlError + * + * Base Error + */ +export class SqlError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/database/sql/events.ts b/database/sql/events.ts new file mode 100644 index 0000000..064a8ad --- /dev/null +++ b/database/sql/events.ts @@ -0,0 +1,227 @@ +import type { + Driver, + DriverConnectionOptions, + DriverParameterType, + DriverQueryMeta, + DriverQueryOptions, + DriverQueryValues, +} from "./driver.ts"; +import type { DriverConnectable } from "./driver.ts"; + +/** + * Event types + */ + +/** + * Client event types + */ +export type ClientEventType = "connect" | "close" | "error"; + +/** + * Pool connection event types + */ +export type PoolConnectionEventType = + | ClientEventType + | "acquire" + | "release"; + +/** + * EventInits + */ + +/** + * SqlErrorEventInit + */ +export interface SqlErrorEventInit< + IConnectable extends DriverConnectable = DriverConnectable, +> extends ErrorEventInit { + connectable?: IConnectable; +} + +/** + * DriverConnectableEventInit + * + * IConnectable event init + */ +export interface DriverEventInit< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, +> extends EventInit { + connection: IDriver; +} + +/** + * Event classes + */ + +/** + * Base error event class + */ +export class SqlErrorEvent< + IEventInit extends SqlErrorEventInit = SqlErrorEventInit, +> extends ErrorEvent { + constructor(type: "error", eventInitDict?: IEventInit) { + super(type, eventInitDict); + } +} + +/** + * Base event class + */ +export class SqlEvent< + IEventType extends PoolConnectionEventType = PoolConnectionEventType, + IEventInit extends DriverEventInit = DriverEventInit, +> extends Event { + constructor(type: IEventType, eventInitDict?: IEventInit) { + super(type, eventInitDict); + } +} + +/** + * Gets dispatched when a connection is established + */ +export class ConnectEvent< + IEventInit extends DriverEventInit = DriverEventInit, +> extends SqlEvent<"connect", IEventInit> { + constructor(eventInitDict: IEventInit) { + super("connect", eventInitDict); + } +} + +/** + * Gets dispatched when a connection is about to be closed + */ +export class CloseEvent< + IEventInit extends DriverEventInit = DriverEventInit, +> extends SqlEvent<"close", IEventInit> { + constructor(eventInitDict: IEventInit) { + super("close", eventInitDict); + } +} + +/** + * Gets dispatched when a connection is acquired from the pool + */ +export class AcquireEvent< + IEventInit extends DriverEventInit = DriverEventInit, +> extends SqlEvent<"acquire", IEventInit> { + constructor(eventInitDict: IEventInit) { + super("acquire", eventInitDict); + } +} + +/** + * Gets dispatched when a connection is released back to the pool + */ +export class ReleaseEvent< + IEventInit extends DriverEventInit = DriverEventInit, +> extends SqlEvent<"release", IEventInit> { + constructor(eventInitDict: IEventInit) { + super("release", eventInitDict); + } +} + +/** + * Event targets + */ + +/** + * EventTarget + * + * The EventTarget to be used + */ +export class SqlEventTarget< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IEventType extends PoolConnectionEventType = PoolConnectionEventType, + IEventInit extends DriverEventInit = DriverEventInit< + IDriver + >, + IEvent extends SqlEvent = SqlEvent< + IEventType, + IEventInit + >, + IListener extends EventListenerOrEventListenerObject = + EventListenerOrEventListenerObject, + IListenerOptions extends AddEventListenerOptions = AddEventListenerOptions, + IRemoveListenerOptions extends EventListenerOptions = EventListenerOptions, +> extends EventTarget { + /** + * With typed events. + * + * @inheritdoc + */ + override addEventListener( + type: IEventType, + listener: IListener | null, + options?: boolean | IListenerOptions, + ): void { + return super.addEventListener(type, listener, options); + } + + /** + * With typed events. + * + * @inheritdoc + */ + override dispatchEvent(event: IEvent): boolean { + return super.dispatchEvent(event); + } + + /** + * With typed events. + * + * @inheritdoc + */ + override removeEventListener( + type: IEventType, + callback: IListener | null, + options?: boolean | IRemoveListenerOptions, + ): void { + return super.removeEventListener(type, callback, options); + } +} + +/** + * Eventable + */ +export interface Eventable< + IEventTarget extends SqlEventTarget = SqlEventTarget, +> { + /** + * The EventTarget to reduce inheritance + */ + eventTarget: IEventTarget; +} diff --git a/database/sql/mod.ts b/database/sql/mod.ts new file mode 100644 index 0000000..ccd2b58 --- /dev/null +++ b/database/sql/mod.ts @@ -0,0 +1,8 @@ +export * from "./asserts.ts"; +export * from "./client.ts"; +export * from "./core.ts"; +export * from "./driver.ts"; +export * from "./errors.ts"; +export * from "./events.ts"; +export * from "./pool.ts"; +export * from "./utils.ts"; diff --git a/database/sql/pool.ts b/database/sql/pool.ts new file mode 100644 index 0000000..915fa0c --- /dev/null +++ b/database/sql/pool.ts @@ -0,0 +1,387 @@ +import type { + Driver, + DriverConnectionOptions, + DriverParameterType, + DriverQueryMeta, + DriverQueryOptions, + DriverQueryValues, +} from "./driver.ts"; +import type { + PreparedStatement, + Transaction, + Transactionable, + TransactionInternalOptions, + TransactionOptions, +} from "./core.ts"; +import type { Eventable, SqlEventTarget } from "./events.ts"; + +/** + * PoolClientOptions + * + * This represents the options for a pool client. + */ +export interface PoolClientOptions { + /** + * The function to call when releasing the connection. + */ + releaseFn?: () => Promise; +} + +export interface PoolClientInternalOptions< + IConnectionOptions extends DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions, + ITransactionOptions extends TransactionOptions, + IPoolClientOptions extends PoolClientOptions, +> extends + TransactionInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions + > { + poolClientOptions: IPoolClientOptions; +} + +/** + * ClientPoolOptions + * + * This represents the options for a connection pool. + */ +export interface ClientPoolOptions { + /** + * Whether to lazily initialize connections. + * + * This means that connections will only be created + * if there are no idle connections available when + * acquiring a connection, and max pool size has not been reached. + */ + lazyInitialization?: boolean; + /** + * The maximum stack size to be allowed. + */ + maxSize?: number; +} + +export interface ClientPoolInternalOptions< + IConnectionOptions extends DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions, + ITransactionOptions extends TransactionOptions, + IPoolClientOptions extends PoolClientOptions, + IClientPoolOptions extends ClientPoolOptions, +> extends + PoolClientInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions, + IPoolClientOptions + > { + clientPoolOptions: IClientPoolOptions; +} + +/** + * PoolClient + * + * This represents a connection to a database from a pool. + * When a user wants to use a connection from a pool, + * they should use a class implementing this interface. + */ +export interface PoolClient< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, + ITransaction extends Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + > = Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + >, + IPoolClientOptions extends PoolClientOptions = PoolClientOptions, +> extends + Transactionable< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction + > { + /** + * The options used to create the pool client + */ + readonly options: PoolClientInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions, + IPoolClientOptions + >; + + /** + * Whether the pool client is disposed and should not be available anymore + */ + readonly disposed: boolean; + /** + * Release the connection to the pool + */ + release(): Promise; +} + +/** + * ClientPool + * + * This represents a pool of connections to a database. + * When a user wants to use a pool of connections to the database, + * they should use a class implementing this interface. + */ +export interface ClientPool< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, + ITransaction extends Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + > = Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + >, + IPoolClientOptions extends PoolClientOptions = PoolClientOptions, + IPoolClient extends PoolClient< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction, + IPoolClientOptions + > = PoolClient< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction, + IPoolClientOptions + >, + IClientPoolOptions extends ClientPoolOptions = ClientPoolOptions, + IEventTarget extends SqlEventTarget = SqlEventTarget, +> extends + Eventable, + Omit< + Driver< + IConnectionOptions + >, + "query" | "ping" + > { + readonly options: ClientPoolInternalOptions< + IConnectionOptions, + IQueryOptions, + ITransactionOptions, + IPoolClientOptions, + IClientPoolOptions + >; + + /** + * Acquire a connection from the pool + */ + acquire(): Promise; +} + +/** + * ClientPoolConstructor + * + * The constructor for the ClientPool interface. + */ +export interface ClientPoolConstructor< + IConnectionOptions extends DriverConnectionOptions = DriverConnectionOptions, + IQueryOptions extends DriverQueryOptions = DriverQueryOptions, + IParameterType extends DriverParameterType = DriverParameterType, + IQueryValues extends DriverQueryValues = DriverQueryValues, + IQueryMeta extends DriverQueryMeta = DriverQueryMeta, + IDriver extends Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + > = Driver< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta + >, + IPreparedStatement extends PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + > = PreparedStatement< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver + >, + ITransactionOptions extends TransactionOptions = TransactionOptions, + ITransaction extends Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + > = Transaction< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions + >, + IPoolClientOptions extends PoolClientOptions = PoolClientOptions, + IPoolClient extends PoolClient< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction, + IPoolClientOptions + > = PoolClient< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction, + IPoolClientOptions + >, + IClientPoolOptions extends ClientPoolOptions = ClientPoolOptions, + IEventTarget extends SqlEventTarget = SqlEventTarget, +> { + new ( + connectionUrl: string | URL, + options?: IConnectionOptions & IQueryOptions, + ): ClientPool< + IConnectionOptions, + IQueryOptions, + IParameterType, + IQueryValues, + IQueryMeta, + IDriver, + IPreparedStatement, + ITransactionOptions, + ITransaction, + IPoolClientOptions, + IPoolClient, + IClientPoolOptions, + IEventTarget + >; +} diff --git a/database/sql/test.ts b/database/sql/test.ts new file mode 100644 index 0000000..36940df --- /dev/null +++ b/database/sql/test.ts @@ -0,0 +1,725 @@ +import { DeferredStack } from "@stdext/collections"; +import { deepMerge } from "@std/collections"; +import { + testClient, + testClientConnection, + testClientConstructorIntegration, + testClientPool, + testClientPoolConnection, + testClientSanity, + testDriver, + testEventTarget, + testPoolClient, + testPreparedStatement, + testTransaction, +} from "./testing.ts"; +import * as Sql from "./mod.ts"; + +const testDbQueryParser = (sql: string) => { + try { + return JSON.parse(sql); + } catch { + return ""; + } +}; + +type TestQueryValues = Sql.DriverQueryValues; +interface TestQueryMeta extends Sql.DriverQueryMeta { + test?: string; +} + +type TestRow = Sql.Row; +type TestArrayRow = Sql.ArrayRow; +type TestParameterType = string; +type TestTransactionOptions = Sql.TransactionOptions; + +interface TestDriverQueryOptions extends Sql.DriverQueryOptions { + test?: string; +} +interface TestDriverConnectionOptions extends Sql.DriverConnectionOptions { + test?: string; +} +interface TestClientPoolOptions extends Sql.ClientPoolOptions { +} +class TestDriver implements + Sql.Driver< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta + > { + readonly connectionUrl: string; + readonly options: Sql.DriverInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions + >; + _connected: boolean = false; + constructor( + connectionUrl: string, + options: TestDriver["options"], + ) { + this.connectionUrl = connectionUrl; + this.options = options; + } + get connected(): boolean { + return this._connected; + } + ping(): Promise { + if (!this.connected) throw new Sql.SqlError("not connected"); + return Promise.resolve(); + } + connect(): Promise { + this._connected = true; + return Promise.resolve(); + } + close(): Promise { + this._connected = false; + return Promise.resolve(); + } + + async *query< + Values extends TestQueryValues = TestQueryValues, + Meta extends Sql.DriverQueryMeta = Sql.DriverQueryMeta, + >( + sql: string, + _params?: unknown[] | undefined, + _options?: Sql.DriverQueryOptions | undefined, + ): AsyncGenerator> { + const queryRes = testDbQueryParser(sql); + for (const row of queryRes) { + const res: Sql.DriverQueryNext = { + columns: Object.keys(row), + values: Object.values(row) as Values, + meta: {} as Meta, + }; + + yield res; + } + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +class TestSqlConnectable implements + Sql.DriverConnectable< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver + > { + readonly options: Sql.DriverInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions + >; + readonly _connection: TestDriver; + get connected(): boolean { + return this.connection.connected; + } + + get connection(): TestDriver { + return this._connection; + } + + constructor( + connection: TestSqlConnectable["connection"], + options: TestSqlConnectable["options"], + ) { + this._connection = connection; + this.options = options; + } + [Symbol.asyncDispose](): Promise { + return this.connection.close(); + } +} + +class TestPreparedStatement extends TestSqlConnectable + implements + Sql.PreparedStatement< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver + > { + sql: string; + constructor( + connection: TestPreparedStatement["connection"], + sql: string, + options: TestPreparedStatement["options"], + ) { + super(connection, options); + this.sql = sql; + } + deallocated = false; + + override get connection(): TestDriver { + if (this.deallocated) throw new Sql.SqlError("deallocated"); + return this._connection; + } + + deallocate(): Promise { + this.deallocated = true; + return Promise.resolve(); + } + execute( + _params?: TestParameterType[] | undefined, + _options?: TestDriverQueryOptions | undefined, + ): Promise { + this.connection; + return Promise.resolve(testDbQueryParser(this.sql)); + } + query( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return Array.fromAsync(this.queryMany(params, options)); + } + queryOne( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return this.query(params, options).then((res) => res[0]) as Promise< + T | undefined + >; + } + queryMany( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): AsyncGenerator { + return Sql.mapObjectIterable( + this.connection.query(this.sql, params, options), + ); + } + queryArray( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return Array.fromAsync(this.queryManyArray(params, options)); + } + queryOneArray( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return this.queryArray(params, options).then((res) => res[0]) as Promise< + T | undefined + >; + } + queryManyArray( + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): AsyncGenerator { + return Sql.mapArrayIterable( + this.connection.query(this.sql, params, options), + ); + } + override [Symbol.asyncDispose](): Promise { + return this.deallocate(); + } +} + +class TestSqlQueriable extends TestSqlConnectable implements + Sql.Queriable< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver + > { + constructor( + connection: TestSqlQueriable["connection"], + options: TestSqlQueriable["options"], + ) { + super(connection, options); + } + execute( + sql: string, + _params?: TestParameterType[] | undefined, + _options?: TestDriverQueryOptions | undefined, + ): Promise { + this.connection; + return Promise.resolve(testDbQueryParser(sql)); + } + query( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return Array.fromAsync(this.queryMany(sql, params, options)); + } + queryOne( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return this.query(sql, params, options).then((res) => res[0]) as Promise< + T | undefined + >; + } + queryMany( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): AsyncGenerator { + return Sql.mapObjectIterable( + this.connection.query(sql, params, options), + ); + } + queryArray( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return Array.fromAsync(this.queryManyArray(sql, params, options)); + } + queryOneArray( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return this.queryArray(sql, params, options).then((res) => + res[0] + ) as Promise; + } + queryManyArray( + sql: string, + params?: TestParameterType[] | undefined, + options?: TestDriverQueryOptions | undefined, + ): AsyncGenerator { + return Sql.mapArrayIterable( + this.connection.query(sql, params, options), + ); + } + sql( + strings: TemplateStringsArray, + ...parameters: string[] + ): Promise { + return this.query(strings.join("?"), parameters); + } + sqlArray( + strings: TemplateStringsArray, + ...parameters: string[] + ): Promise { + return this.queryArray(strings.join("?"), parameters); + } +} + +class TestSqlPreparable extends TestSqlQueriable implements + Sql.Preparable< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement + > { + constructor( + connection: TestSqlPreparable["connection"], + options: TestSqlPreparable["options"], + ) { + super(connection, options); + } + prepare( + sql: string, + options?: TestDriverQueryOptions | undefined, + ): Promise { + return Promise.resolve( + new TestPreparedStatement( + this.connection, + sql, + deepMerge(this.options, { + queryOptions: options, + }), + ), + ); + } +} + +class TestTransaction extends TestSqlPreparable implements + Sql.Transaction< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement, + TestTransactionOptions + > { + declare readonly options: Sql.TransactionInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestTransactionOptions + >; + _inTransaction: boolean = false; + get inTransaction(): boolean { + return this._inTransaction; + } + + override get connection(): TestDriver { + if (!this.inTransaction) { + throw new Sql.SqlError("not in transaction"); + } + return super.connection; + } + + constructor( + connection: TestTransaction["connection"], + options: TestTransaction["options"], + ) { + super(connection, options); + this._inTransaction = true; + } + commitTransaction( + _options?: Record | undefined, + ): Promise { + this._inTransaction = false; + return Promise.resolve(); + } + rollbackTransaction( + _options?: Record | undefined, + ): Promise { + this._inTransaction = false; + return Promise.resolve(); + } + createSavepoint(_name?: string | undefined): Promise { + return Promise.resolve(); + } + releaseSavepoint(_name?: string | undefined): Promise { + return Promise.resolve(); + } +} + +class TestTransactionable extends TestSqlPreparable implements + Sql.Preparable< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement + > { + declare readonly options: Sql.TransactionInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestTransactionOptions + >; + + constructor( + connection: TestTransactionable["connection"], + options: TestTransactionable["options"], + ) { + super(connection, options); + } + beginTransaction( + _options?: Record | undefined, + ): Promise { + return Promise.resolve( + new TestTransaction(this.connection, this.options), + ); + } + transaction( + fn: ( + t: TestTransaction, + ) => Promise, + ): Promise { + return fn(new TestTransaction(this.connection, this.options)); + } +} + +type TestConnectionEventInit = Sql.DriverEventInit; + +class TestSqlEventTarget extends Sql.SqlEventTarget< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + Sql.PoolConnectionEventType, + TestConnectionEventInit, + Sql.SqlEvent, + EventListenerOrEventListenerObject, + AddEventListenerOptions, + EventListenerOptions +> { +} + +class TestClient extends TestTransactionable implements + Sql.Client< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement, + TestTransactionOptions, + TestTransaction, + TestSqlEventTarget + > { + eventTarget: TestSqlEventTarget; + constructor( + connectionUrl: string | URL, + options: TestTransactionable["options"], + ) { + const driver = new TestDriver(connectionUrl.toString(), options); + super(driver, options); + this.eventTarget = new TestSqlEventTarget(); + } + async connect(): Promise { + await this.connection.connect(); + this.eventTarget.dispatchEvent( + new Sql.ConnectEvent({ connection: this.connection }), + ); + } + async close(): Promise { + this.eventTarget.dispatchEvent( + new Sql.CloseEvent({ connection: this.connection }), + ); + await this.connection.close(); + } +} + +interface TestPoolClientOptions extends Sql.PoolClientOptions { +} + +class TestPoolClient extends TestTransactionable implements + Sql.PoolClient< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement, + TestTransactionOptions, + TestTransaction, + TestPoolClientOptions + > { + declare readonly options: Sql.PoolClientInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestTransactionOptions, + TestPoolClientOptions + >; + + #releaseFn?: () => Promise; + + #disposed: boolean = false; + get disposed(): boolean { + return this.#disposed; + } + + constructor( + connection: TestPoolClient["connection"], + options: TestPoolClient["options"], + ) { + super(connection, options); + if (this.options?.poolClientOptions.releaseFn) { + this.#releaseFn = this.options?.poolClientOptions.releaseFn; + } + } + async release() { + this.#disposed = true; + await this.#releaseFn?.(); + } + + override [Symbol.asyncDispose](): Promise { + return this.release(); + } +} + +class TestClientPool implements + Sql.ClientPool< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestParameterType, + TestQueryValues, + TestQueryMeta, + TestDriver, + TestPreparedStatement, + TestTransactionOptions, + TestTransaction, + TestPoolClientOptions, + TestPoolClient + > { + declare readonly options: Sql.ClientPoolInternalOptions< + TestDriverConnectionOptions, + TestDriverQueryOptions, + TestTransactionOptions, + TestPoolClientOptions, + TestClientPoolOptions + >; + + deferredStack: DeferredStack; + eventTarget: TestSqlEventTarget; + connectionUrl: string; + _connected: boolean = false; + get connected(): boolean { + return this._connected; + } + constructor( + connectionUrl: string | URL, + options: TestClientPool["options"], + ) { + this.connectionUrl = connectionUrl.toString(); + this.options = options; + this.deferredStack = new DeferredStack({ + maxSize: 3, + removeFn: async (element) => { + await element._value.close(); + }, + }); + this.eventTarget = new TestSqlEventTarget(); + } + async connect(): Promise { + for (let i = 0; i < this.deferredStack.maxSize; i++) { + const conn = new TestDriver( + this.connectionUrl, + this.options, + ); + if (!this.options.clientPoolOptions.lazyInitialization) { + await conn.connect(); + this.eventTarget.dispatchEvent( + new Sql.ConnectEvent({ connection: conn }), + ); + } + this.deferredStack.add(conn); + } + } + async close(): Promise { + for (const el of this.deferredStack.elements) { + this.eventTarget.dispatchEvent( + new Sql.CloseEvent({ connection: el._value }), + ); + await el.remove(); + } + } + async acquire(): Promise { + const el = await this.deferredStack.pop(); + this.eventTarget.dispatchEvent( + new Sql.AcquireEvent({ connection: el.value }), + ); + const c = new TestPoolClient( + el.value, + deepMerge( + this.options, + { + poolClientOptions: { + releaseFn: async () => { + this.eventTarget.dispatchEvent( + new Sql.ReleaseEvent({ connection: el._value }), + ); + await el.release(); + }, + }, + }, + ), + ); + return c; + } + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +const connectionUrl = "test"; +const options: TestClientPool["options"] = { + clientPoolOptions: {}, + connectionOptions: {}, + poolClientOptions: {}, + queryOptions: {}, + transactionOptions: {}, +}; +const sql = "test"; + +const connection = new TestDriver(connectionUrl, options); +const preparedStatement = new TestPreparedStatement( + connection, + sql, + options, +); +const transaction = new TestTransaction(connection, options); +const eventTarget = new TestSqlEventTarget(); +const client = new TestClient(connectionUrl, options); +const poolClient = new TestPoolClient(connection, options); +const clientPool = new TestClientPool(connectionUrl, options); + +const expects = { + connectionUrl, + options, + clientPoolOptions: options, + sql, +}; + +Deno.test(`sql static test`, async (t) => { + await t.step("Driver", () => { + testDriver(connection, expects); + }); + + await t.step(`sql/PreparedStatement`, () => { + testPreparedStatement(preparedStatement, expects); + }); + + await t.step(`sql/Transaction`, () => { + testTransaction(transaction, expects); + }); + + await t.step(`sql/SqlEventTarget`, () => { + testEventTarget(eventTarget); + }); + + await t.step(`sql/Client`, () => { + testClient(client, expects); + }); + + await t.step(`sql/PoolClient`, () => { + testPoolClient(poolClient, expects); + }); + + await t.step(`sql/ClientPool`, () => { + testClientPool(clientPool, expects); + }); +}); + +Deno.test(`sql connection test`, async (t) => { + await t.step("Client", async (t) => { + await testClientConnection( + t, + TestClient, + [connectionUrl, options], + ); + }); + await t.step("Client", async (t) => { + await testClientPoolConnection( + t, + TestClientPool, + [connectionUrl, options], + ); + }); +}); + +Deno.test(`sql sanity test`, async (t) => { + await t.step("Client", async (t) => { + await t.step("test suite", async (t) => { + await testClientConstructorIntegration(t, TestClient, [ + connectionUrl, + options, + ]); + }); + await testClientSanity( + t, + TestClient, + [connectionUrl, options], + ); + }); +}); diff --git a/database/sql/testing.ts b/database/sql/testing.ts new file mode 100644 index 0000000..cfd4fff --- /dev/null +++ b/database/sql/testing.ts @@ -0,0 +1,588 @@ +import { + assert, + assertEquals, + assertFalse, + assertInstanceOf, + assertRejects, +} from "@std/assert"; +import { + assertIsClient, + assertIsClientPool, + assertIsDriver, + assertIsDriverConnectable, + assertIsEventable, + assertIsPoolClient, + assertIsPreparable, + assertIsPreparedStatement, + assertIsQueriable, + assertIsTransaction, + assertIsTransactionable, + type Client, + type ClientPool, + type DriverConnectable, + type DriverConstructor, + type PoolClient, + type PreparedStatement, + type Queriable, + type Transaction, + type Transactionable, +} from "./mod.ts"; +import { deepMerge } from "@std/collections"; +import type { AnyConstructor } from "@stdext/types"; +import { assertIsConnectionUrl, assertIsDriverOptions } from "./asserts.ts"; + +export type ClientConstructorArguments< + IClient extends DriverConnectable = DriverConnectable, +> = [ + string, + IClient["options"], +]; +export type ClientPoolConstructorArguments< + IClient extends ClientPool = ClientPool, +> = [string, IClient["options"]]; +export type ClientConstructor< + IClient extends DriverConnectable = DriverConnectable, +> = AnyConstructor>; +export type ClientPoolConstructor< + IClient extends ClientPool = ClientPool, +> = AnyConstructor>; + +/** + * Test the Driver class + * @param value The Client + * @param expects The values to test against + */ +export function testDriver( + value: unknown, + expects: { + connectionUrl: string | URL; + }, +) { + assertIsDriver(value); + assertEquals(value.connectionUrl, expects.connectionUrl); +} + +/** + * Test the Driver class + * @param value The Client + * @param expects The values to test against + */ +export function testDriverConstructor< + IDriverConstructor extends DriverConstructor = DriverConstructor, +>( + DriverC: IDriverConstructor, + args: ConstructorParameters, +) { + assert( + args.length < 3, + "Number of arguments for the driver constructor has to be max 2.", + ); + assertIsConnectionUrl(args[0]); + assertIsDriverOptions(args[1]); + // @ts-expect-error: ts inference + const d = new DriverC(...args); + testDriver(d, { connectionUrl: args[0] }); +} + +/** + * Test the Driver class + * @param value The Client + * @param expects The values to test against + */ +export async function testDriverConstructorIntegration< + IDriverConstructor extends DriverConstructor = DriverConstructor, +>( + t: Deno.TestContext, + D: IDriverConstructor, + args: ConstructorParameters, +) { + testDriverConstructor(D, args); + + await t.step("testConnectAndClose", async (t) => { + await t.step("should connect and close with using", async () => { + // @ts-expect-error: ts-inference + await using d = new D(...args); + + await d.connect(); + }); + + await t.step("should connect and close", async () => { + // @ts-expect-error: ts-inference + const d = new D(...args); + + await d.connect(); + await d.close(); + }); + + await t.step("ping should work while connected", async () => { + // @ts-expect-error: ts-inference + await using d = new D(...args); + + await d.connect(); + assert(d.connected); + + await d.ping(); + + await d.close(); + + assertFalse(d.connected); + + await assertRejects(async () => { + await d.ping(); + }); + }); + }); +} + +/** + * Test the DriverConnectable class + * @param value The DriverConnectable + * @param expects The values to test against + */ +export function testDriverConnectable( + value: unknown, + expects: { + connectionUrl: string; + options: DriverConnectable["options"]; + }, +) { + assertIsDriverConnectable(value); + assertEquals(value.options, expects.options); + testDriver(value.connection, expects); +} + +/** + * Tests the connection of a Client + */ +export function testClientConstructor< + IClient extends Client = Client, +>( + ClientC: ClientConstructor, + args: ClientConstructorArguments, +): void { + assert( + args.length < 3, + "Number of arguments for the client constructor has to be max 2.", + ); + assertIsConnectionUrl(args[0]); + assertEquals(typeof args[1], "object"); + const d = new ClientC(...args); + testClient(d, { connectionUrl: args[0], options: args[1] }); +} +/** + * Tests the connection of a Client + */ +export async function testClientConstructorIntegration< + IClient extends Client = Client, +>( + t: Deno.TestContext, + Client: ClientConstructor, + args: ClientConstructorArguments, +): Promise { + testClientConstructor(Client, args); + + await t.step("testConnectAndClose", async (t) => { + await t.step("should connect and close with using", async () => { + await using db = new Client(...args); + + await db.connect(); + }); + + await t.step("should connect and close", async () => { + const db = new Client(...args); + + await db.connect(); + + await db.close(); + }); + + await t.step("should connect and close with events", async () => { + const db = new Client(...args); + + let connectListenerCalled = false; + let closeListenerCalled = false; + let error: Error | undefined = undefined; + + try { + db.eventTarget.addEventListener("connect", () => { + connectListenerCalled = true; + }); + + db.eventTarget.addEventListener("close", () => { + closeListenerCalled = true; + }); + + await db.connect(); + await db.close(); + } catch (e) { + error = e as Error; + } + + assert( + connectListenerCalled, + "Connect listener not called: " + error?.message, + ); + assert( + closeListenerCalled, + "Close listener not called: " + error?.message, + ); + }); + }); +} + +/** + * Test the PreparedStatement class + * @param value The PreparedStatement + * @param expects The values to test against + */ +export function testPreparedStatement( + value: unknown, + expects: { + connectionUrl: string; + options: PreparedStatement["options"]; + sql: string; + }, +) { + assertIsPreparedStatement(value); + testDriverConnectable(value, expects); + assertEquals(value.sql, expects.sql); +} + +/** + * Test the Queriable class + * @param value The Queriable + * @param expects The values to test against + */ +export function testQueriable( + value: unknown, + expects: { + connectionUrl: string; + options: Queriable["options"]; + }, +) { + assertIsQueriable(value); + testDriverConnectable(value, expects); +} + +/** + * Test the Preparable class + * @param value The Preparable + * @param expects The values to test against + */ +export function testPreparable( + value: unknown, + expects: { + connectionUrl: string; + options: Queriable["options"]; + }, +) { + assertIsPreparable(value); + testQueriable(value, expects); +} + +/** + * Test the Transaction class + * @param value The Transaction + * @param expects The values to test against + */ +export function testTransaction( + value: unknown, + expects: { + connectionUrl: string; + options: Transaction["options"]; + }, +) { + assertIsTransaction(value); + testPreparable(value, expects); +} + +/** + * Test the Transactionable class + * @param value The Transactionable + * @param expects The values to test against + */ +export function testTransactionable( + value: unknown, + expects: { + connectionUrl: string; + options: Transactionable["options"]; + }, +) { + assertIsTransactionable(value); + testPreparable(value, expects); +} + +/** + * Test the EventTarget class + * @param value The EventTarget + */ +export function testEventTarget( + value: unknown, +) { + assertInstanceOf(value, EventTarget); +} + +/** + * Test the Eventable class + * @param value The Eventable + */ +export function testEventable( + value: unknown, +) { + assertIsEventable(value); + testEventTarget(value.eventTarget); +} + +/** + * Test the Client class + * @param value The Client + * @param expects The values to test against + */ +export function testClient( + value: unknown, + expects: { + connectionUrl: string; + options: Client["options"]; + }, +) { + assertIsClient(value); + testTransactionable(value, expects); + testEventable(value); +} + +/** + * Test the PoolClient class + * @param value The PoolClient + * @param expects The values to test against + */ +export function testPoolClient( + value: unknown, + expects: { + connectionUrl: string; + options: PoolClient["options"]; + }, +) { + assertIsPoolClient(value); + testTransactionable(value, expects); +} + +/** + * Test the ClientPool class + * @param value The ClientPool + * @param expects The values to test against + */ +export function testClientPool( + value: unknown, + expects: { + connectionUrl: string; + options: ClientPool["options"]; + }, +) { + assertIsClientPool(value); + testEventable(value); + assertEquals(value.connectionUrl, expects.connectionUrl); +} + +/** + * Tests the connection of a Client + */ +export async function testClientConnection< + IClient extends Client = Client, +>( + t: Deno.TestContext, + Client: ClientConstructor, + clientArguments: ClientConstructorArguments, +): Promise { + await t.step("testConnectAndClose", async (t) => { + await t.step("should connect and close with using", async () => { + await using db = new Client(...clientArguments); + + await db.connect(); + }); + + await t.step("should connect and close", async () => { + const db = new Client(...clientArguments); + + await db.connect(); + + await db.close(); + }); + + await t.step("should connect and close with events", async () => { + const db = new Client(...clientArguments); + + let connectListenerCalled = false; + let closeListenerCalled = false; + let error: Error | undefined = undefined; + + try { + db.eventTarget.addEventListener("connect", () => { + connectListenerCalled = true; + }); + + db.eventTarget.addEventListener("close", () => { + closeListenerCalled = true; + }); + + await db.connect(); + await db.close(); + } catch (e) { + error = e as Error; + } + + assert( + connectListenerCalled, + "Connect listener not called: " + error?.message, + ); + assert( + closeListenerCalled, + "Close listener not called: " + error?.message, + ); + }); + }); +} + +/** + * Tests the connection of a ClientPool + */ +export async function testClientPoolConnection< + IClient extends ClientPool = ClientPool, +>( + t: Deno.TestContext, + Client: ClientPoolConstructor, + clientArguments: ClientPoolConstructorArguments, +): Promise { + await t.step("testConnectAndClose", async (t) => { + await t.step("should connect and close", async () => { + const db = new Client(...clientArguments); + + assertEquals(db.connected, false); + + await db.connect(); + + await db.close(); + }); + await t.step("should connect and close with using", async () => { + const opts = deepMerge( + clientArguments[1], + // @ts-expect-error: ts-inference + { + clientPoolOptions: { + lazyInitialization: true, + }, + }, + ); + await using db = new Client( + clientArguments[0], + opts, + ); + let connectListenerCalled = false; + + db.eventTarget.addEventListener("connect", () => { + connectListenerCalled = true; + }); + + await db.connect(); + + assertFalse( + connectListenerCalled, + "Connect listener called, but should not have been due to lazyInitialization", + ); + }); + await t.step("should connect and close with events", async () => { + const db = new Client(clientArguments[0], { + ...clientArguments[1], + lazyInitialization: false, + }); + + let connectListenerCalled = false; + let closeListenerCalled = false; + let error: Error | undefined = undefined; + + try { + db.eventTarget.addEventListener("connect", () => { + connectListenerCalled = true; + }); + + db.eventTarget.addEventListener("close", () => { + closeListenerCalled = true; + }); + + await db.connect(); + await db.close(); + } catch (e) { + error = e as Error; + } + + assertEquals( + connectListenerCalled, + true, + "Connect listener not called: " + error?.message, + ); + assertEquals( + closeListenerCalled, + true, + "Close listener not called: " + error?.message, + ); + }); + }); +} + +export async function testClientSanity< + IClient extends Client = Client, +>( + t: Deno.TestContext, + Client: ClientConstructor, + clientArguments: ClientConstructorArguments, +): Promise { + await testClientConnection(t, Client, clientArguments); + + const client = new Client(...clientArguments); + + await client.connect(); + + // Testing prepared statements + + const stmt1 = await client.prepare("select 1 as one;"); + + assertIsPreparedStatement(stmt1); + assertFalse(stmt1.deallocated); + + await using stmt2 = await client.prepare("select 1 as one;"); + + assertIsPreparedStatement(stmt2); + assertFalse(stmt2.deallocated); + + await stmt1.execute(); + await stmt1.deallocate(); + + assert(stmt1.deallocated); + + await assertRejects(async () => { + await stmt1.execute(); + }); + + await stmt2.execute(); + + // Testing transactions + + const transaction1 = await client.beginTransaction(); + + assert(transaction1.inTransaction, "Transaction is not in transaction"); + + await transaction1.execute("select 1 as one;"); + + await transaction1.commitTransaction(); + + await assertRejects(async () => { + await transaction2.execute("select 1 as one;"); + }); + + assertFalse(transaction1.inTransaction); + + await using transaction2 = await client.beginTransaction(); + + assert(transaction2.inTransaction, "Transaction is not in transaction"); + + await transaction2.execute("select 1 as one;"); +} diff --git a/database/sql/utils.test.ts b/database/sql/utils.test.ts new file mode 100644 index 0000000..09d6833 --- /dev/null +++ b/database/sql/utils.test.ts @@ -0,0 +1,82 @@ +import { assertEquals } from "@std/assert"; +import { + getObjectFromRow, + mapArrayIterable, + mapObjectIterable, +} from "./utils.ts"; +import type { DriverQueryNext } from "./driver.ts"; + +Deno.test("getObjectFromRow", async (t) => { + await t.step("empty row", () => { + assertEquals(getObjectFromRow({ columns: [], values: [], meta: {} }), {}); + }); + + await t.step("filled row", () => { + assertEquals( + getObjectFromRow({ columns: ["a", "b"], values: ["c", 1], meta: {} }), + { a: "c", b: 1 }, + ); + }); + + await t.step("more columns row", () => { + assertEquals( + getObjectFromRow({ columns: ["a", "b"], values: ["c"], meta: {} }), + { a: "c", b: undefined }, + ); + }); + + await t.step("more values row", () => { + assertEquals( + getObjectFromRow({ columns: ["a"], values: ["c", 1], meta: {} }), + { a: "c" }, + ); + }); +}); + +Deno.test("mapArrayIterable", async (t) => { + await t.step("empty row", async () => { + const itt = async function* () { + yield { columns: [], values: [], meta: {} } as DriverQueryNext; + }; + + const actual = await Array.fromAsync(mapArrayIterable(itt())); + assertEquals(actual, [[]]); + }); + + await t.step("filled row", async () => { + const itt = async function* () { + yield { + columns: ["a", "b"], + values: ["c", 1], + meta: {}, + } as DriverQueryNext; + }; + + const actual = await Array.fromAsync(mapArrayIterable(itt())); + assertEquals(actual, [["c", 1]]); + }); +}); + +Deno.test("mapObjectIterable", async (t) => { + await t.step("empty row", async () => { + const itt = async function* () { + yield { columns: [], values: [], meta: {} } as DriverQueryNext; + }; + + const actual = await Array.fromAsync(mapObjectIterable(itt())); + assertEquals(actual, [{}]); + }); + + await t.step("filled row", async () => { + const itt = async function* () { + yield { + columns: ["a", "b"], + values: ["c", 1], + meta: {}, + } as DriverQueryNext; + }; + + const actual = await Array.fromAsync(mapObjectIterable(itt())); + assertEquals(actual, [{ a: "c", b: 1 }]); + }); +}); diff --git a/database/sql/utils.ts b/database/sql/utils.ts new file mode 100644 index 0000000..3c44e5d --- /dev/null +++ b/database/sql/utils.ts @@ -0,0 +1,42 @@ +import type { DriverQueryNext } from "./driver.ts"; + +/** + * Takes a row object and returns a mapped object + */ +export function getObjectFromRow< + Output extends Record = Record, + Row extends DriverQueryNext = DriverQueryNext, +>(row: Row): Output { + const rowObject: Output = {} as Output; + + for (let i = 0; i < row.columns.length; i++) { + // @ts-expect-error: ts-inference + rowObject[row.columns[i]] = row.values[i]; + } + + return rowObject; +} + +/** + * Takes a row iterable and maps only the values + */ +export async function* mapArrayIterable< + Output extends Array = Array, + Row extends DriverQueryNext = DriverQueryNext, +>(q: AsyncIterable): AsyncGenerator { + for await (const row of q) { + yield row.values as Output; + } +} + +/** + * Takes a row iterable and maps an object + */ +export async function* mapObjectIterable< + Output extends Record = Record, + Row extends DriverQueryNext = DriverQueryNext, +>(q: AsyncIterable): AsyncGenerator { + for await (const row of q) { + yield getObjectFromRow(row); + } +} diff --git a/deno.json b/deno.json index 1b1f425..3b30ce9 100644 --- a/deno.json +++ b/deno.json @@ -2,12 +2,15 @@ "lock": false, "imports": { "@std/assert": "jsr:@std/assert@^1", + "@std/collections": "jsr:@std/collections@^1", "@std/encoding": "jsr:@std/encoding@^1", "@std/json": "jsr:@std/json@^1", "@std/path": "jsr:@std/path@^1", "@std/toml": "jsr:@std/toml@^1", "@stdext/assert": "jsr:@stdext/assert@^0", + "@stdext/collections": "jsr:@stdext/collections@^0", "@stdext/crypto": "jsr:@stdext/crypto@^0", + "@stdext/database": "jsr:@stdext/database@^0", "@stdext/encoding": "jsr:@stdext/encoding@^0", "@stdext/http": "jsr:@stdext/http@^0", "@stdext/json": "jsr:@stdext/json@^0", @@ -17,6 +20,7 @@ "tasks": { "check": "deno task format:check && deno lint && deno check **/*.ts", "test": "RUST_BACKTRACE=1 deno test --unstable-http --unstable-webgpu --allow-all --parallel --coverage --trace-leaks", + "test:inspect": "RUST_BACKTRACE=1 deno test --unstable-http --unstable-webgpu --allow-all --parallel --coverage --trace-leaks --inspect-wait", "cov:gen": "deno coverage coverage --lcov --output=cov.lcov", "build:wasm": "deno run --allow-read --allow-run _tools/build_wasm.ts", "build:wasm:check": "deno task build:wasm --check", @@ -27,7 +31,9 @@ }, "workspace": [ "./assert", + "./collections", "./crypto", + "./database", "./encoding", "./http", "./json", diff --git a/encoding/hex.test.ts b/encoding/hex.test.ts index 0b8146f..19cc306 100644 --- a/encoding/hex.test.ts +++ b/encoding/hex.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert"; import { dump } from "./hex.ts"; -Deno.test("dump", async (t) => { +Deno.test("encoding/hex/dump", async (t) => { const data = "This is a test string that is longer than 16 bytes and will be split into multiple lines for the hexdump. The quick brown fox jumps over the lazy dog. Foo bar baz."; const buffer8Compatible = new TextEncoder().encode(data); diff --git a/types/mod.ts b/types/mod.ts index cb941fd..4cbc2d9 100644 --- a/types/mod.ts +++ b/types/mod.ts @@ -8,11 +8,23 @@ export type FlipMap> = { /** * Make properties K in T optional + * + * @example + * ```ts + * type A = { a: string, b: string } + * type B = PartialBy // { a: string, b?: string } + * ``` */ export type PartialBy = Omit & Partial>; /** * Make properties K in T required + * + * @example + * ```ts + * type A = { a?: string, b?: string } + * type B = RequiredBy // { a?: string, b: string } + * ``` */ export type RequiredBy = & Omit @@ -20,6 +32,12 @@ export type RequiredBy = /** * Make properties K in T required, and the rest partial + * + * @example + * ```ts + * type A = { a: string, b?: string, c?: string } + * type B = RequiredPartialBy // { a?: string, b: string, c?: string } + * ``` */ export type RequiredPartialBy = & RequiredBy< @@ -48,6 +66,40 @@ export type WriteableBy = & Writeable>; /** - * Gets the values of an object + * Gets the values of a Record + * + * @example With type + * ```ts + * type A = { a: "hello", b: "world" } + * type B = ValueOf // "hello"|"world" + * ``` + * + * @example With object + * ```ts + * const a = { a: "hello", b: "world" } as const + * type B = ValueOf // "hello"|"world" + * ``` */ export type ValueOf = T[keyof T]; + +/** + * Represents a generic constructor + * + * @example As argument + * ```ts + * import type { AnyConstructor } from "@stdext/typings"; + * + * function(SomeClass: AnyConstructor){ + * const c = new SomeClass() + * } + * ``` + * + * @example For other type + * ```ts + * import type { AnyConstructor } from "@stdext/typings"; + * + * type SomeConstructor = AnyConstructor + * ``` + */ +// deno-lint-ignore no-explicit-any +export type AnyConstructor = new (...args: A) => T;