From d3ed45add7eac3c0138e7ec6c2fcb5d981b12155 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Fri, 5 Jul 2024 12:25:10 +0200 Subject: [PATCH] Added the first implementation of the Pongo implementation --- src/package-lock.json | 8 ++ src/package.json | 7 +- src/packages/pongo/package.json | 5 +- src/packages/pongo/src/index.ts | 2 + src/packages/pongo/src/main/client.ts | 13 +++ src/packages/pongo/src/main/dbClient.ts | 16 +++ src/packages/pongo/src/main/index.ts | 3 + .../pongo/src/{postgres => main}/typing.ts | 46 +++++--- src/packages/pongo/src/postgres/client.ts | 109 ++++++++++++++++++ .../pongo/src/postgres/execute/index.ts | 28 +++++ .../pongo/src/postgres/filter/index.ts | 50 ++++++++ src/packages/pongo/src/postgres/index.ts | 2 + src/packages/pongo/src/postgres/pool.ts | 35 ++++++ .../pongo/src/postgres/update/index.ts | 49 ++++++++ 14 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 src/packages/pongo/src/main/client.ts create mode 100644 src/packages/pongo/src/main/dbClient.ts create mode 100644 src/packages/pongo/src/main/index.ts rename src/packages/pongo/src/{postgres => main}/typing.ts (73%) create mode 100644 src/packages/pongo/src/postgres/client.ts create mode 100644 src/packages/pongo/src/postgres/execute/index.ts create mode 100644 src/packages/pongo/src/postgres/filter/index.ts create mode 100644 src/packages/pongo/src/postgres/index.ts create mode 100644 src/packages/pongo/src/postgres/pool.ts create mode 100644 src/packages/pongo/src/postgres/update/index.ts diff --git a/src/package-lock.json b/src/package-lock.json index 73790c0..c8ddc58 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -37,6 +37,7 @@ "node": ">=20.11.1" }, "peerDependencies": { + "close-with-grace": "^1.3.0", "pg": "^8.12.0", "pg-format": "^1.0.4" } @@ -2129,6 +2130,12 @@ "node": ">= 6" } }, + "node_modules/close-with-grace": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/close-with-grace/-/close-with-grace-1.3.0.tgz", + "integrity": "sha512-lvm0rmLIR5bNz4CRKW6YvCfn9Wg5Wb9A8PJ3Bb+hjyikgC1RO1W3J4z9rBXQYw97mAte7dNSQI8BmUsxdlXQyw==", + "peer": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5512,6 +5519,7 @@ }, "peerDependencies": { "@types/uuid": "^9.0.8", + "close-with-grace": "^1.3.0", "pg": "^8.12.0", "pg-format": "^1.0.4", "uuid": "^9.0.1" diff --git a/src/package.json b/src/package.json index b1e355f..0fa5e98 100644 --- a/src/package.json +++ b/src/package.json @@ -62,11 +62,11 @@ "dist" ], "devDependencies": { + "@faker-js/faker": "8.4.1", + "@types/mongodb": "^4.0.7", "@types/node": "20.11.30", "@types/pg": "^8.11.6", "@types/pg-format": "^1.0.5", - "@faker-js/faker": "8.4.1", - "@types/mongodb": "^4.0.7", "@types/uuid": "9.0.8", "@typescript-eslint/eslint-plugin": "7.9.0", "@typescript-eslint/parser": "7.9.0", @@ -86,7 +86,8 @@ }, "peerDependencies": { "pg": "^8.12.0", - "pg-format": "^1.0.4" + "pg-format": "^1.0.4", + "close-with-grace": "^1.3.0" }, "workspaces": [ "packages/pongo" diff --git a/src/packages/pongo/package.json b/src/packages/pongo/package.json index 9ec51d4..9fd6137 100644 --- a/src/packages/pongo/package.json +++ b/src/packages/pongo/package.json @@ -48,9 +48,10 @@ ], "peerDependencies": { "@types/uuid": "^9.0.8", - "uuid": "^9.0.1", + "close-with-grace": "^1.3.0", "pg": "^8.12.0", - "pg-format": "^1.0.4" + "pg-format": "^1.0.4", + "uuid": "^9.0.1" }, "devDependencies": { "@types/node": "20.11.30", diff --git a/src/packages/pongo/src/index.ts b/src/packages/pongo/src/index.ts index e69de29..1e1c8cd 100644 --- a/src/packages/pongo/src/index.ts +++ b/src/packages/pongo/src/index.ts @@ -0,0 +1,2 @@ +export * from './main'; +export * from './postgres'; diff --git a/src/packages/pongo/src/main/client.ts b/src/packages/pongo/src/main/client.ts new file mode 100644 index 0000000..b3ccadc --- /dev/null +++ b/src/packages/pongo/src/main/client.ts @@ -0,0 +1,13 @@ +import { getDbClient } from './dbClient'; +import type { PongoClient, PongoDb } from './typing'; + +export const pongoClient = (connectionString: string): PongoClient => { + const dbClient = getDbClient(connectionString); + + return { + connect: () => dbClient.connect(), + close: () => dbClient.close(), + db: (dbName?: string): PongoDb => + dbName ? getDbClient(connectionString, dbName) : dbClient, + }; +}; diff --git a/src/packages/pongo/src/main/dbClient.ts b/src/packages/pongo/src/main/dbClient.ts new file mode 100644 index 0000000..fa31ead --- /dev/null +++ b/src/packages/pongo/src/main/dbClient.ts @@ -0,0 +1,16 @@ +import { postgresClient } from '../postgres'; +import type { PongoCollection } from './typing'; + +export interface DbClient { + connect(): Promise; + close(): Promise; + collection: (name: string) => PongoCollection; +} + +export const getDbClient = ( + connectionString: string, + database?: string, +): DbClient => { + // This is the place where in the future could come resolution of other database types + return postgresClient(connectionString, database); +}; diff --git a/src/packages/pongo/src/main/index.ts b/src/packages/pongo/src/main/index.ts new file mode 100644 index 0000000..cbc1b7c --- /dev/null +++ b/src/packages/pongo/src/main/index.ts @@ -0,0 +1,3 @@ +export * from './client'; +export * from './dbClient'; +export * from './typing'; diff --git a/src/packages/pongo/src/postgres/typing.ts b/src/packages/pongo/src/main/typing.ts similarity index 73% rename from src/packages/pongo/src/postgres/typing.ts rename to src/packages/pongo/src/main/typing.ts index ac63898..19049d1 100644 --- a/src/packages/pongo/src/postgres/typing.ts +++ b/src/packages/pongo/src/main/typing.ts @@ -1,4 +1,27 @@ -// src/pongoTypes.ts +export interface PongoClient { + connect(): Promise; + + close(): Promise; + + db(dbName?: string): PongoDb; +} + +export interface PongoDb { + collection(name: string): PongoCollection; +} + +export interface PongoCollection { + createCollection(): Promise; + insertOne(document: T): Promise; + updateOne( + filter: PongoFilter, + update: PongoUpdate, + ): Promise; + deleteOne(filter: PongoFilter): Promise; + findOne(filter: PongoFilter): Promise; + find(filter: PongoFilter): Promise; +} + export type PongoFilter = { [P in keyof T]?: T[P] | PongoFilterOperator; }; @@ -12,7 +35,6 @@ export type PongoFilterOperator = { $ne?: T; $in?: T[]; $nin?: T[]; - // Add more operators as needed }; export type PongoUpdate = { @@ -20,29 +42,17 @@ export type PongoUpdate = { $unset?: { [P in keyof T]?: '' }; $inc?: { [P in keyof T]?: number }; $push?: { [P in keyof T]?: T[P] }; - // Add more update operators as needed }; export interface PongoInsertResult { - insertedId: string; + insertedId: string | null; + insertedCount: number | null; } export interface PongoUpdateResult { - modifiedCount: number; + modifiedCount: number | null; } export interface PongoDeleteResult { - deletedCount: number; -} - -export interface PongoCollection { - createCollection(): Promise; - insertOne(document: T): Promise; - updateOne( - filter: PongoFilter, - update: PongoUpdate, - ): Promise; - deleteOne(filter: PongoFilter): Promise; - findOne(filter: PongoFilter): Promise; - find(filter: PongoFilter): Promise; + deletedCount: number | null; } diff --git a/src/packages/pongo/src/postgres/client.ts b/src/packages/pongo/src/postgres/client.ts new file mode 100644 index 0000000..1ab35ab --- /dev/null +++ b/src/packages/pongo/src/postgres/client.ts @@ -0,0 +1,109 @@ +import type { Pool } from 'pg'; +import { v4 as uuid } from 'uuid'; +import { + type DbClient, + type PongoCollection, + type PongoDeleteResult, + type PongoFilter, + type PongoInsertResult, + type PongoUpdate, + type PongoUpdateResult, +} from '../main'; +import { constructFilterQuery } from './filter'; +import { getPool } from './pool'; +import { constructUpdateQuery } from './update'; +import { sql } from './execute'; + +export const postgresClient = ( + connectionString: string, + database?: string, +): DbClient => { + const pool = getPool({ connectionString, database }); + + return { + connect: () => Promise.resolve(), + close: () => Promise.resolve(), + collection: (name: string) => postgresCollection(name, pool), + }; +}; + +export const postgresCollection = ( + collectionName: string, + pool: Pool, +): PongoCollection => { + const createCollection = async (): Promise => { + await sql( + pool, + 'CREATE TABLE IF NOT EXISTS %I (id UUID PRIMARY KEY, data JSONB)', + collectionName, + ); + }; + + return { + createCollection, + insertOne: async (document: T): Promise => { + await createCollection(); + + const id = uuid(); + + const result = await sql( + pool, + 'INSERT INTO %I (id, data) VALUES (%L, %L)', + collectionName, + id, + JSON.stringify({ ...document, _id: id }), + ); + + return result.rowCount + ? { insertedId: id, insertedCount: result.rowCount } + : { insertedId: null, insertedCount: null }; + }, + updateOne: async ( + filter: PongoFilter, + update: PongoUpdate, + ): Promise => { + const filterQuery = constructFilterQuery(filter); + const updateQuery = constructUpdateQuery(update); + + const result = await sql( + pool, + 'UPDATE %I SET data = %s WHERE %s', + collectionName, + updateQuery, + filterQuery, + ); + return { modifiedCount: result.rowCount }; + }, + deleteOne: async (filter: PongoFilter): Promise => { + const filterQuery = constructFilterQuery(filter); + const result = await sql( + pool, + 'DELETE FROM %I WHERE %s', + collectionName, + filterQuery, + ); + return { deletedCount: result.rowCount }; + }, + findOne: async (filter: PongoFilter): Promise => { + const filterQuery = constructFilterQuery(filter); + const result = await sql( + pool, + 'SELECT data FROM %I WHERE %s LIMIT 1', + collectionName, + filterQuery, + ); + return (result.rows[0]?.data ?? null) as T | null; + }, + find: async (filter: PongoFilter): Promise => { + const filterQuery = constructFilterQuery(filter); + const result = await sql( + pool, + 'SELECT data FROM %I WHERE %s LIMIT 1', + collectionName, + filterQuery, + ); + + return result.rows.map((row) => row.data as T); + }, + }; +}; diff --git a/src/packages/pongo/src/postgres/execute/index.ts b/src/packages/pongo/src/postgres/execute/index.ts new file mode 100644 index 0000000..a7e3d5c --- /dev/null +++ b/src/packages/pongo/src/postgres/execute/index.ts @@ -0,0 +1,28 @@ +import type { QueryResultRow, Pool, QueryResult, PoolClient } from 'pg'; +import format from 'pg-format'; + +export const sql = async ( + pool: Pool, + sqlText: string, + ...params: unknown[] +): Promise> => { + const client = await pool.connect(); + try { + const query = format(sqlText, ...params); + return await client.query(query); + } finally { + client.release(); + } +}; + +export const execute = async ( + pool: Pool, + handle: (client: PoolClient) => Promise, +) => { + const client = await pool.connect(); + try { + return await handle(client); + } finally { + client.release(); + } +}; diff --git a/src/packages/pongo/src/postgres/filter/index.ts b/src/packages/pongo/src/postgres/filter/index.ts new file mode 100644 index 0000000..cfb0719 --- /dev/null +++ b/src/packages/pongo/src/postgres/filter/index.ts @@ -0,0 +1,50 @@ +import format from 'pg-format'; +import type { PongoFilter } from '../../main'; + +export const constructFilterQuery = (filter: PongoFilter): string => { + const filters = Object.entries(filter).map(([key, value]) => { + if (typeof value === 'object' && !Array.isArray(value)) { + return constructComplexFilterQuery(key, value as Record); + } else { + return format('data->>%I = %L', key, value); + } + }); + return filters.join(' AND '); +}; + +export const constructComplexFilterQuery = ( + key: string, + value: Record, +): string => { + const subFilters = Object.entries(value).map(([operator, val]) => { + switch (operator) { + case '$eq': + return format('data->>%I = %L', key, val); + case '$gt': + return format('data->>%I > %L', key, val); + case '$gte': + return format('data->>%I >= %L', key, val); + case '$lt': + return format('data->>%I < %L', key, val); + case '$lte': + return format('data->>%I <= %L', key, val); + case '$ne': + return format('data->>%I != %L', key, val); + case '$in': + return format( + 'data->>%I IN (%s)', + key, + (val as unknown[]).map((v) => format('%L', v)).join(', '), + ); + case '$nin': + return format( + 'data->>%I NOT IN (%s)', + key, + (val as unknown[]).map((v) => format('%L', v)).join(', '), + ); + default: + throw new Error(`Unsupported operator: ${operator}`); + } + }); + return subFilters.join(' AND '); +}; diff --git a/src/packages/pongo/src/postgres/index.ts b/src/packages/pongo/src/postgres/index.ts new file mode 100644 index 0000000..b9936bb --- /dev/null +++ b/src/packages/pongo/src/postgres/index.ts @@ -0,0 +1,2 @@ +export * from './client'; +export * from './pool'; diff --git a/src/packages/pongo/src/postgres/pool.ts b/src/packages/pongo/src/postgres/pool.ts new file mode 100644 index 0000000..a860a4a --- /dev/null +++ b/src/packages/pongo/src/postgres/pool.ts @@ -0,0 +1,35 @@ +import { Pool, type PoolConfig } from 'pg'; + +const pools: Map = new Map(); + +export const getPool = ( + connectionStringOrOptions: string | PoolConfig, +): Pool => { + const connectionString = + typeof connectionStringOrOptions === 'string' + ? connectionStringOrOptions + : connectionStringOrOptions.connectionString!; + + const poolOptions = + typeof connectionStringOrOptions === 'string' + ? { connectionString } + : connectionStringOrOptions; + + return ( + pools.get(connectionString) ?? + pools.set(connectionString, new Pool(poolOptions)).get(connectionString)! + ); +}; + +export const endPool = async (connectionString: string): Promise => { + const pool = pools.get(connectionString); + if (pool) { + await pool.end(); + pools.delete(connectionString); + } +}; + +export const endAllPools = () => + Promise.all( + [...pools.keys()].map((connectionString) => endPool(connectionString)), + ); diff --git a/src/packages/pongo/src/postgres/update/index.ts b/src/packages/pongo/src/postgres/update/index.ts new file mode 100644 index 0000000..2e961e6 --- /dev/null +++ b/src/packages/pongo/src/postgres/update/index.ts @@ -0,0 +1,49 @@ +import format from 'pg-format'; +import type { PongoUpdate } from '../../main'; + +export const constructUpdateQuery = (update: PongoUpdate): string => { + let updateQuery = 'data'; + + if ('$set' in update) { + const setUpdate = update.$set!; + updateQuery = format( + 'jsonb_set(%s, %L, data || %L)', + updateQuery, + '{}', + JSON.stringify(setUpdate), + ); + } + + if ('$unset' in update) { + const unsetUpdate = Object.keys(update.$unset!); + updateQuery = format('%s - %L', updateQuery, unsetUpdate.join(', ')); + } + + if ('$inc' in update) { + const incUpdate = update.$inc!; + for (const [key, value] of Object.entries(incUpdate)) { + updateQuery = format( + "jsonb_set(%s, '{%s}', to_jsonb((data->>'%s')::numeric + %L))", + updateQuery, + key, + key, + value, + ); + } + } + + if ('$push' in update) { + const pushUpdate = update.$push!; + for (const [key, value] of Object.entries(pushUpdate)) { + updateQuery = format( + "jsonb_set(%s, '{%s}', (COALESCE(data->'%s', '[]'::jsonb) || to_jsonb(%L)))", + updateQuery, + key, + key, + value, + ); + } + } + + return updateQuery; +};